@actuate-media/cms-admin 0.73.0 → 0.75.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/CHANGELOG.md +28 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/LoginRoot.d.ts.map +1 -1
- package/dist/LoginRoot.js +2 -1
- package/dist/LoginRoot.js.map +1 -1
- package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
- package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
- package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
- package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
- package/dist/__tests__/views/portal-container.test.js +13 -0
- package/dist/__tests__/views/portal-container.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.js +3 -6
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +15 -4
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CoauthorResultPopover.js +1 -1
- package/dist/components/EditorSeoAside.d.ts +23 -0
- package/dist/components/EditorSeoAside.d.ts.map +1 -0
- package/dist/components/EditorSeoAside.js +21 -0
- package/dist/components/EditorSeoAside.js.map +1 -0
- package/dist/components/MediaPickerModal.js +1 -1
- package/dist/components/SEOConfigPanel.js +1 -1
- package/dist/components/SchedulePublishDialog.js +1 -1
- package/dist/components/SharePreviewLinkDialog.js +1 -1
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +2 -1
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/VersionHistory.js +1 -1
- package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
- package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
- package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
- package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
- package/dist/components/seo/SeoAccordion.d.ts +16 -0
- package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
- package/dist/components/seo/SeoAccordion.js +21 -0
- package/dist/components/seo/SeoAccordion.js.map +1 -0
- package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
- package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
- package/dist/components/seo/SeoAnalysisList.js +32 -0
- package/dist/components/seo/SeoAnalysisList.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +15 -4
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +155 -35
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
- package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
- package/dist/components/seo/SeoInsightsMetrics.js +16 -0
- package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +13 -3
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
- package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
- package/dist/components/seo/SeoLinkingSection.js +44 -0
- package/dist/components/seo/SeoLinkingSection.js.map +1 -0
- package/dist/components/seo/primitives.d.ts.map +1 -1
- package/dist/components/seo/primitives.js +3 -3
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/components/ui/Card.js +5 -5
- package/dist/components/ui/Card.js.map +1 -1
- package/dist/components/ui/InspectorPane.js +3 -3
- package/dist/components/ui/InspectorPane.js.map +1 -1
- package/dist/components/ui/PanelSection.d.ts +16 -0
- package/dist/components/ui/PanelSection.d.ts.map +1 -0
- package/dist/components/ui/PanelSection.js +15 -0
- package/dist/components/ui/PanelSection.js.map +1 -0
- package/dist/lib/admin-root-element.d.ts +9 -0
- package/dist/lib/admin-root-element.d.ts.map +1 -0
- package/dist/lib/admin-root-element.js +14 -0
- package/dist/lib/admin-root-element.js.map +1 -0
- package/dist/lib/editor-seo-content.d.ts +16 -0
- package/dist/lib/editor-seo-content.d.ts.map +1 -0
- package/dist/lib/editor-seo-content.js +139 -0
- package/dist/lib/editor-seo-content.js.map +1 -0
- package/dist/lib/portal-container.d.ts.map +1 -1
- package/dist/lib/portal-container.js +2 -3
- package/dist/lib/portal-container.js.map +1 -1
- package/dist/lib/seo-service.d.ts +91 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +55 -2
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.js +2 -2
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +12 -14
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +35 -6
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.js +1 -1
- package/dist/views/Posts/PostTypeEditor.js +3 -3
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +4 -2
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/ScriptTags.js +1 -1
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Users.js +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +6 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +5 -2
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +36 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +2 -13
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +2 -2
- package/dist/views/page-editor/SectionsPanel.js +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +17 -79
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeaderPanel.js +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +37 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +7 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/LinksTab.d.ts +4 -0
- package/dist/views/seo/LinksTab.d.ts.map +1 -0
- package/dist/views/seo/LinksTab.js +111 -0
- package/dist/views/seo/LinksTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +39 -6
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.js +2 -2
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/AppearanceTab.js +1 -1
- package/dist/views/settings/BrandVoiceCard.js +1 -1
- package/dist/views/settings/EmailSettingsTab.js +2 -2
- package/dist/views/settings/GeneralSettingsTab.js +1 -1
- package/dist/views/settings/SecurityStatusCard.js +1 -1
- package/dist/views/settings/SeoSettingsTab.js +4 -4
- package/dist/views/settings/UpdatesTab.js +2 -2
- package/dist/views/settings/components.js +3 -3
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +3 -2
- package/src/LoginRoot.tsx +2 -1
- package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
- package/src/__tests__/views/portal-container.test.tsx +14 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
- package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
- package/src/__tests__/views/seo-content-pane.render.test.tsx +16 -4
- package/src/components/CoauthorResultPopover.tsx +1 -1
- package/src/components/EditorSeoAside.tsx +68 -0
- package/src/components/MediaPickerModal.tsx +1 -1
- package/src/components/SEOConfigPanel.tsx +2 -2
- package/src/components/SchedulePublishDialog.tsx +1 -1
- package/src/components/SharePreviewLinkDialog.tsx +1 -1
- package/src/components/ThemeProvider.tsx +3 -1
- package/src/components/VersionHistory.tsx +1 -1
- package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
- package/src/components/seo/SeoAccordion.tsx +76 -0
- package/src/components/seo/SeoAnalysisList.tsx +113 -0
- package/src/components/seo/SeoEditorPane.tsx +532 -250
- package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
- package/src/components/seo/SeoLinkingSection.tsx +151 -0
- package/src/components/seo/primitives.tsx +7 -3
- package/src/components/ui/Card.tsx +5 -5
- package/src/components/ui/InspectorPane.tsx +3 -3
- package/src/components/ui/PanelSection.tsx +53 -0
- package/src/lib/admin-root-element.ts +15 -0
- package/src/lib/editor-seo-content.ts +144 -0
- package/src/lib/portal-container.ts +3 -2
- package/src/lib/seo-service.ts +150 -2
- package/src/views/ApiKeys.tsx +2 -2
- package/src/views/Dashboard.tsx +40 -53
- package/src/views/MediaBrowser.tsx +62 -12
- package/src/views/PageEditor.tsx +1 -1
- package/src/views/Posts/PostTypeEditor.tsx +3 -3
- package/src/views/SEO.tsx +6 -0
- package/src/views/ScriptTags.tsx +2 -2
- package/src/views/Users.tsx +1 -1
- package/src/views/page-editor/EditorTopBar.tsx +27 -0
- package/src/views/page-editor/PageSectionEditor.tsx +58 -3
- package/src/views/page-editor/PageSettingsModal.tsx +1 -45
- package/src/views/page-editor/SectionInspector.tsx +2 -2
- package/src/views/page-editor/SectionsPanel.tsx +1 -1
- package/src/views/post-editor/PostFieldsModal.tsx +19 -138
- package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +60 -3
- package/src/views/seo/ContentTab.tsx +11 -5
- package/src/views/seo/LinksTab.tsx +462 -0
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +97 -4
- package/src/views/seo/TechnicalTab.tsx +2 -2
- package/src/views/settings/AISettingsTab.tsx +3 -3
- package/src/views/settings/AiFeaturesCard.tsx +1 -1
- package/src/views/settings/AppearanceTab.tsx +1 -1
- package/src/views/settings/BrandVoiceCard.tsx +2 -2
- package/src/views/settings/EmailSettingsTab.tsx +2 -2
- package/src/views/settings/GeneralSettingsTab.tsx +3 -3
- package/src/views/settings/SecurityStatusCard.tsx +1 -1
- package/src/views/settings/SeoSettingsTab.tsx +6 -6
- package/src/views/settings/UpdatesTab.tsx +3 -3
- package/src/views/settings/components.tsx +3 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReadabilityResult } from '@actuate-media/cms-core/seo/analysis'
|
|
4
|
+
import { prominentWordsFromText } from '../../lib/editor-seo-content.js'
|
|
5
|
+
|
|
6
|
+
export function SeoInsightsMetrics({
|
|
7
|
+
readability,
|
|
8
|
+
plainText,
|
|
9
|
+
}: {
|
|
10
|
+
readability: ReadabilityResult
|
|
11
|
+
plainText: string
|
|
12
|
+
}) {
|
|
13
|
+
const prominent = prominentWordsFromText(plainText)
|
|
14
|
+
const needsMoreCopy = readability.wordCount < 50
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="space-y-4">
|
|
18
|
+
<div>
|
|
19
|
+
<h4 className="mb-1.5 text-[12.5px] font-medium text-[var(--txt)]">Prominent words</h4>
|
|
20
|
+
{needsMoreCopy ? (
|
|
21
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
22
|
+
Once you add more copy, we'll list the words that occur most in the content — a
|
|
23
|
+
quick read on what the page focuses on.
|
|
24
|
+
</p>
|
|
25
|
+
) : prominent.length > 0 ? (
|
|
26
|
+
<div className="flex flex-wrap gap-1.5">
|
|
27
|
+
{prominent.map((word) => (
|
|
28
|
+
<span
|
|
29
|
+
key={word}
|
|
30
|
+
className="rounded-full border border-[var(--bdr)] bg-[var(--card)] px-2 py-0.5 text-[11.5px] text-[var(--txt)]"
|
|
31
|
+
>
|
|
32
|
+
{word}
|
|
33
|
+
</span>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
) : (
|
|
37
|
+
<p className="text-[11.5px] text-[var(--sub)]">No recurring terms yet.</p>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div className="grid grid-cols-2 gap-3">
|
|
42
|
+
<MetricCard
|
|
43
|
+
label="Reading time"
|
|
44
|
+
value={String(readability.readingTimeMinutes)}
|
|
45
|
+
unit="minutes"
|
|
46
|
+
hint={
|
|
47
|
+
needsMoreCopy
|
|
48
|
+
? 'Continue writing to estimate reading time.'
|
|
49
|
+
: `${readability.wordCount} words at ~200 wpm`
|
|
50
|
+
}
|
|
51
|
+
/>
|
|
52
|
+
<MetricCard
|
|
53
|
+
label="Word count"
|
|
54
|
+
value={String(readability.wordCount)}
|
|
55
|
+
unit="words"
|
|
56
|
+
hint={needsMoreCopy ? 'Add body content to unlock metrics.' : undefined}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
|
|
61
|
+
<div className="mb-1 text-[12.5px] font-medium text-[var(--txt)]">Flesch reading ease</div>
|
|
62
|
+
{needsMoreCopy ? (
|
|
63
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
64
|
+
Your text should be slightly longer to calculate readability. Keep writing to unlock
|
|
65
|
+
this score.
|
|
66
|
+
</p>
|
|
67
|
+
) : (
|
|
68
|
+
<>
|
|
69
|
+
<div className="flex items-baseline gap-2">
|
|
70
|
+
<span className="text-2xl font-medium text-[var(--acc)]">
|
|
71
|
+
{readability.fleschScore}
|
|
72
|
+
</span>
|
|
73
|
+
<span className="text-[11.5px] text-[var(--muted)]">out of 100</span>
|
|
74
|
+
</div>
|
|
75
|
+
<p className="mt-1 text-[11.5px] text-[var(--sub)]">
|
|
76
|
+
{readability.fleschLabel} —{' '}
|
|
77
|
+
{readability.fleschScore >= 60
|
|
78
|
+
? 'Content is fairly easy to read.'
|
|
79
|
+
: 'Try shorter sentences and simpler words.'}
|
|
80
|
+
</p>
|
|
81
|
+
</>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function MetricCard({
|
|
89
|
+
label,
|
|
90
|
+
value,
|
|
91
|
+
unit,
|
|
92
|
+
hint,
|
|
93
|
+
}: {
|
|
94
|
+
label: string
|
|
95
|
+
value: string
|
|
96
|
+
unit: string
|
|
97
|
+
hint?: string
|
|
98
|
+
}) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
|
|
101
|
+
<div className="text-[11.5px] font-medium text-[var(--sub)]">{label}</div>
|
|
102
|
+
<div className="mt-1 flex items-baseline gap-1">
|
|
103
|
+
<span className="text-xl font-medium text-[var(--acc)]">{value}</span>
|
|
104
|
+
<span className="text-[11px] text-[var(--muted)]">{unit}</span>
|
|
105
|
+
</div>
|
|
106
|
+
{hint && <p className="mt-1 text-[10.5px] text-[var(--muted)]">{hint}</p>}
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -321,7 +321,11 @@ export function SeoIssueFixPanel({
|
|
|
321
321
|
toast.error(res.error)
|
|
322
322
|
return
|
|
323
323
|
}
|
|
324
|
-
toast.success(
|
|
324
|
+
toast.success(
|
|
325
|
+
suggestion?.fixStrategy === 'fix-link'
|
|
326
|
+
? 'Link updated in content and issue resolved.'
|
|
327
|
+
: 'Redirect created and issue resolved.',
|
|
328
|
+
)
|
|
325
329
|
onChanged?.()
|
|
326
330
|
}
|
|
327
331
|
|
|
@@ -408,11 +412,15 @@ export function SeoIssueFixPanel({
|
|
|
408
412
|
{suggestion.source === 'ai' && !suggestion.aiUnavailable ? (
|
|
409
413
|
<span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
|
|
410
414
|
<Sparkles className="h-3 w-3" aria-hidden />
|
|
411
|
-
|
|
415
|
+
{suggestion.fixStrategy === 'fix-link'
|
|
416
|
+
? 'Fix link in content'
|
|
417
|
+
: 'AI suggestion'}
|
|
412
418
|
</span>
|
|
413
419
|
) : suggestion.source === 'deterministic' ? (
|
|
414
420
|
<span className="bg-muted text-muted-foreground rounded-full px-2.5 py-0.5 text-xs font-medium">
|
|
415
|
-
|
|
421
|
+
{suggestion.fixStrategy === 'fix-link'
|
|
422
|
+
? 'Content link fix'
|
|
423
|
+
: 'Rule-based fix'}
|
|
416
424
|
</span>
|
|
417
425
|
) : null}
|
|
418
426
|
</div>
|
|
@@ -483,7 +491,11 @@ export function SeoIssueFixPanel({
|
|
|
483
491
|
disabled={busy}
|
|
484
492
|
>
|
|
485
493
|
<Check className="h-4 w-4" aria-hidden />
|
|
486
|
-
{busy
|
|
494
|
+
{busy
|
|
495
|
+
? 'Applying…'
|
|
496
|
+
: suggestion.fixStrategy === 'fix-link'
|
|
497
|
+
? 'Fix link in content'
|
|
498
|
+
: 'Approve redirect'}
|
|
487
499
|
</button>
|
|
488
500
|
) : inlineFix && suggestion?.autoFixable && !inlineApplyEnabled ? (
|
|
489
501
|
<PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Link2, Loader2, Sparkles } from 'lucide-react'
|
|
5
|
+
import { fetchLinkSuggestions, type LinkSuggestion } from '../../lib/seo-service.js'
|
|
6
|
+
|
|
7
|
+
export function SeoLinkingSection({
|
|
8
|
+
entityType,
|
|
9
|
+
entityId,
|
|
10
|
+
internalLinks,
|
|
11
|
+
externalLinks,
|
|
12
|
+
internalLinkOk,
|
|
13
|
+
externalLinkOk,
|
|
14
|
+
}: {
|
|
15
|
+
entityType: 'page' | 'post'
|
|
16
|
+
entityId: string | null
|
|
17
|
+
internalLinks: number
|
|
18
|
+
externalLinks: number
|
|
19
|
+
internalLinkOk: boolean
|
|
20
|
+
externalLinkOk: boolean
|
|
21
|
+
}) {
|
|
22
|
+
const [loading, setLoading] = useState(false)
|
|
23
|
+
const [suggestions, setSuggestions] = useState<LinkSuggestion[]>([])
|
|
24
|
+
const [error, setError] = useState<string | null>(null)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!entityId) {
|
|
28
|
+
setSuggestions([])
|
|
29
|
+
setError(null)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
let alive = true
|
|
33
|
+
setLoading(true)
|
|
34
|
+
setError(null)
|
|
35
|
+
fetchLinkSuggestions({ targetEntityType: entityType, targetEntityId: entityId, limit: 5 })
|
|
36
|
+
.then((res) => {
|
|
37
|
+
if (!alive) return
|
|
38
|
+
if (res.error) {
|
|
39
|
+
setError(res.error)
|
|
40
|
+
setSuggestions([])
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
setSuggestions(res.data?.suggestions ?? [])
|
|
44
|
+
})
|
|
45
|
+
.finally(() => alive && setLoading(false))
|
|
46
|
+
return () => {
|
|
47
|
+
alive = false
|
|
48
|
+
}
|
|
49
|
+
}, [entityType, entityId])
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="space-y-4">
|
|
53
|
+
<div className="grid grid-cols-2 gap-3">
|
|
54
|
+
<LinkCountCard
|
|
55
|
+
label="Internal links"
|
|
56
|
+
count={internalLinks}
|
|
57
|
+
ok={internalLinkOk}
|
|
58
|
+
hint={
|
|
59
|
+
internalLinkOk
|
|
60
|
+
? 'Links to other pages on your site.'
|
|
61
|
+
: 'Add links to related content — aim for at least one.'
|
|
62
|
+
}
|
|
63
|
+
/>
|
|
64
|
+
<LinkCountCard
|
|
65
|
+
label="Outbound links"
|
|
66
|
+
count={externalLinks}
|
|
67
|
+
ok={externalLinkOk}
|
|
68
|
+
hint={
|
|
69
|
+
externalLinkOk
|
|
70
|
+
? 'Links to external sources.'
|
|
71
|
+
: 'Consider linking to authoritative external sources.'
|
|
72
|
+
}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{!entityId ? (
|
|
77
|
+
<p className="rounded-[7px] border border-[var(--bdr)] bg-[var(--bg)] p-3 text-[11.5px] text-[var(--sub)]">
|
|
78
|
+
Save a draft first to load AI link opportunities for this page.
|
|
79
|
+
</p>
|
|
80
|
+
) : loading ? (
|
|
81
|
+
<div className="flex items-center gap-2 text-[12px] text-[var(--muted)]">
|
|
82
|
+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
83
|
+
Finding link opportunities…
|
|
84
|
+
</div>
|
|
85
|
+
) : error ? (
|
|
86
|
+
<p className="text-[11.5px] text-[var(--err)]">{error}</p>
|
|
87
|
+
) : suggestions.length > 0 ? (
|
|
88
|
+
<div>
|
|
89
|
+
<div className="mb-2 flex items-center gap-1.5 text-[12px] font-medium text-[var(--txt)]">
|
|
90
|
+
<Sparkles className="h-3.5 w-3.5 text-[var(--acc)]" aria-hidden />
|
|
91
|
+
Link opportunities
|
|
92
|
+
</div>
|
|
93
|
+
<ul className="space-y-2">
|
|
94
|
+
{suggestions.map((s) => (
|
|
95
|
+
<li
|
|
96
|
+
key={`${s.sourceEntityId}-${s.anchorText}`}
|
|
97
|
+
className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-2.5"
|
|
98
|
+
>
|
|
99
|
+
<div className="text-[12.5px] font-medium text-[var(--txt)]">{s.sourceTitle}</div>
|
|
100
|
+
<p className="mt-0.5 text-[11px] text-[var(--muted)]">{s.sourceUrl}</p>
|
|
101
|
+
<p className="mt-1 text-[11.5px] text-[var(--sub)]">
|
|
102
|
+
Suggested anchor: <span className="text-[var(--txt)]">{s.anchorText}</span>
|
|
103
|
+
</p>
|
|
104
|
+
{s.excerpt && (
|
|
105
|
+
<p className="mt-1 line-clamp-2 text-[11px] text-[var(--muted)]">{s.excerpt}</p>
|
|
106
|
+
)}
|
|
107
|
+
</li>
|
|
108
|
+
))}
|
|
109
|
+
</ul>
|
|
110
|
+
</div>
|
|
111
|
+
) : (
|
|
112
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
113
|
+
No inbound link suggestions yet. Run the site link index from SEO → Links to improve AI
|
|
114
|
+
recommendations.
|
|
115
|
+
</p>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
<div className="flex items-start gap-2 rounded-[7px] border border-[var(--bdr)] bg-[var(--bg)] p-2.5 text-[11px] text-[var(--sub)]">
|
|
119
|
+
<Link2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--acc)]" aria-hidden />
|
|
120
|
+
<span>
|
|
121
|
+
Best practice: mix internal links (site structure + crawl paths) with selective outbound
|
|
122
|
+
links (trust signals). We flag counts here; apply links in the content editor.
|
|
123
|
+
</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function LinkCountCard({
|
|
130
|
+
label,
|
|
131
|
+
count,
|
|
132
|
+
ok,
|
|
133
|
+
hint,
|
|
134
|
+
}: {
|
|
135
|
+
label: string
|
|
136
|
+
count: number
|
|
137
|
+
ok: boolean
|
|
138
|
+
hint: string
|
|
139
|
+
}) {
|
|
140
|
+
return (
|
|
141
|
+
<div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
|
|
142
|
+
<div className="flex items-center justify-between gap-2">
|
|
143
|
+
<span className="text-[11.5px] font-medium text-[var(--sub)]">{label}</span>
|
|
144
|
+
<span className={`text-lg font-medium ${ok ? 'text-[var(--suc)]' : 'text-[var(--warn)]'}`}>
|
|
145
|
+
{count}
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
<p className="mt-1 text-[10.5px] text-[var(--muted)]">{hint}</p>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -153,14 +153,18 @@ export function SectionCard({
|
|
|
153
153
|
{action}
|
|
154
154
|
</CardHeader>
|
|
155
155
|
)}
|
|
156
|
-
<CardContent className={title || action ? '
|
|
156
|
+
<CardContent className={title || action ? '' : 'pt-6!'}>{children}</CardContent>
|
|
157
157
|
</Card>
|
|
158
158
|
)
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
export function SeoLoading({ label = 'Loading…' }: { label?: string }) {
|
|
162
162
|
return (
|
|
163
|
-
<div
|
|
163
|
+
<div
|
|
164
|
+
className="flex min-h-32 items-center justify-center py-12"
|
|
165
|
+
role="status"
|
|
166
|
+
aria-live="polite"
|
|
167
|
+
>
|
|
164
168
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
|
|
165
169
|
<span className="sr-only">{label}</span>
|
|
166
170
|
</div>
|
|
@@ -195,7 +199,7 @@ export function SeoEmptyState({
|
|
|
195
199
|
export function SeoErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
|
196
200
|
return (
|
|
197
201
|
<div
|
|
198
|
-
className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-
|
|
202
|
+
className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-4"
|
|
199
203
|
role="alert"
|
|
200
204
|
>
|
|
201
205
|
<AlertTriangle className="text-destructive h-5 w-5 shrink-0" aria-hidden />
|
|
@@ -10,8 +10,8 @@ const card = cv(
|
|
|
10
10
|
/** Stat / compact surfaces (`.dstat`). */
|
|
11
11
|
sm: 'px-4 py-3.5',
|
|
12
12
|
/** Simple padded panel (`.dcard-b`). */
|
|
13
|
-
md: 'px-
|
|
14
|
-
lg: 'px-
|
|
13
|
+
md: 'px-6 py-4',
|
|
14
|
+
lg: 'px-6 py-6',
|
|
15
15
|
},
|
|
16
16
|
elevation: {
|
|
17
17
|
flat: 'shadow-none',
|
|
@@ -55,7 +55,7 @@ export function CardHeader({
|
|
|
55
55
|
}: HTMLAttributes<HTMLDivElement> & { bordered?: boolean }) {
|
|
56
56
|
return (
|
|
57
57
|
<div
|
|
58
|
-
className={[bordered ? 'border-b border-[var(--bdr)] px-
|
|
58
|
+
className={[bordered ? 'border-b border-[var(--bdr)] px-6 pt-6 pb-4' : '', className]
|
|
59
59
|
.filter(Boolean)
|
|
60
60
|
.join(' ')}
|
|
61
61
|
{...rest}
|
|
@@ -96,7 +96,7 @@ export function CardDescription({
|
|
|
96
96
|
export function CardContent({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
97
97
|
return (
|
|
98
98
|
<div
|
|
99
|
-
className={`px-
|
|
99
|
+
className={`px-6 pt-4 pb-6 text-[length:var(--fs-body)] leading-[1.55] text-[var(--sub)] ${className}`}
|
|
100
100
|
{...rest}
|
|
101
101
|
>
|
|
102
102
|
{children}
|
|
@@ -107,7 +107,7 @@ export function CardContent({ className = '', children, ...rest }: HTMLAttribute
|
|
|
107
107
|
export function CardFooter({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
108
108
|
return (
|
|
109
109
|
<div
|
|
110
|
-
className={`flex items-center justify-end gap-2 border-t border-[var(--bdr)] px-
|
|
110
|
+
className={`flex items-center justify-end gap-2 border-t border-[var(--bdr)] px-6 py-4 ${className}`}
|
|
111
111
|
{...rest}
|
|
112
112
|
>
|
|
113
113
|
{children}
|
|
@@ -86,7 +86,7 @@ export function InspectorPaneHeader({
|
|
|
86
86
|
closeLabel?: string
|
|
87
87
|
}) {
|
|
88
88
|
return (
|
|
89
|
-
<div className="flex items-start gap-2 border-b border-[var(--bdr)] px-
|
|
89
|
+
<div className="flex items-start gap-2 border-b border-[var(--bdr)] px-5 py-4">
|
|
90
90
|
<div className="min-w-0 flex-1">{children}</div>
|
|
91
91
|
<button
|
|
92
92
|
type="button"
|
|
@@ -115,7 +115,7 @@ export function InspectorPaneSection({
|
|
|
115
115
|
children: ReactNode
|
|
116
116
|
}) {
|
|
117
117
|
return (
|
|
118
|
-
<div className="border-b border-[var(--bdr)] px-
|
|
118
|
+
<div className="border-b border-[var(--bdr)] px-5 py-4 last:border-b-0">
|
|
119
119
|
{title && (
|
|
120
120
|
<div className="mb-2.5 flex items-baseline gap-2">
|
|
121
121
|
<span className="text-[10.5px] font-semibold tracking-[0.6px] text-[var(--muted)] uppercase">
|
|
@@ -130,5 +130,5 @@ export function InspectorPaneSection({
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
export function InspectorPaneFooter({ children }: { children: ReactNode }) {
|
|
133
|
-
return <div className="border-t border-[var(--bdr)] px-
|
|
133
|
+
return <div className="border-t border-[var(--bdr)] px-5 py-4">{children}</div>
|
|
134
134
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bordered panel with a header divider — Dashboard, modals, and list-heavy views.
|
|
7
|
+
* Matches Settings/Section card rhythm: `px-6` gutters, `py-4` header, body flush
|
|
8
|
+
* below the divider with its own padding.
|
|
9
|
+
*/
|
|
10
|
+
export function PanelSection({
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
action,
|
|
14
|
+
children,
|
|
15
|
+
className = '',
|
|
16
|
+
}: {
|
|
17
|
+
title?: ReactNode
|
|
18
|
+
description?: ReactNode
|
|
19
|
+
action?: ReactNode
|
|
20
|
+
children: ReactNode
|
|
21
|
+
className?: string
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<section
|
|
25
|
+
className={`bg-card border-border overflow-hidden rounded-xl border shadow-sm ${className}`}
|
|
26
|
+
>
|
|
27
|
+
{(title || action) && (
|
|
28
|
+
<header className="border-border flex items-center justify-between gap-3 border-b px-6 py-4">
|
|
29
|
+
<div className="min-w-0">
|
|
30
|
+
{title &&
|
|
31
|
+
(typeof title === 'string' ? (
|
|
32
|
+
<h2 className="text-foreground text-sm font-medium">{title}</h2>
|
|
33
|
+
) : (
|
|
34
|
+
title
|
|
35
|
+
))}
|
|
36
|
+
{description &&
|
|
37
|
+
(typeof description === 'string' ? (
|
|
38
|
+
<p className="text-muted-foreground mt-0.5 text-xs">{description}</p>
|
|
39
|
+
) : (
|
|
40
|
+
description
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
{action}
|
|
44
|
+
</header>
|
|
45
|
+
)}
|
|
46
|
+
{children}
|
|
47
|
+
</section>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Standard inset for list rows / table bodies inside a {@link PanelSection}. */
|
|
52
|
+
export const PANEL_LIST_ROW_CLASS =
|
|
53
|
+
'hover:bg-accent/50 focus-visible:bg-accent/50 w-full px-6 py-4 text-left transition-colors'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Marks the canonical admin shell root (AdminRoot / LoginRoot). */
|
|
2
|
+
export const ADMIN_ROOT_ATTR = 'data-actuate-admin-root'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the element that owns admin theme tokens (accent, color mode, density).
|
|
6
|
+
* Prefer the marked shell root so a consumer wrapper `.actuate-admin` does not
|
|
7
|
+
* steal runtime overrides from the inner AdminRoot.
|
|
8
|
+
*/
|
|
9
|
+
export function getAdminRootElement(): HTMLElement | null {
|
|
10
|
+
if (typeof document === 'undefined') return null
|
|
11
|
+
return (
|
|
12
|
+
document.querySelector<HTMLElement>(`[${ADMIN_ROOT_ATTR}]`) ??
|
|
13
|
+
document.querySelector<HTMLElement>('.actuate-admin')
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { getSectionType, type PageSection } from '@actuate-media/cms-core/page-sections'
|
|
2
|
+
import { analyzeContent } from '@actuate-media/cms-core/seo/analysis'
|
|
3
|
+
|
|
4
|
+
const HTML_LIKE = /<\/?[a-z][\s\S]*>/i
|
|
5
|
+
|
|
6
|
+
/** Build an HTML-ish string from editor sections (+ optional post body) for SEO analysis. */
|
|
7
|
+
export function buildEditorContentHtml(sections: PageSection[], body = ''): string {
|
|
8
|
+
const parts: string[] = []
|
|
9
|
+
if (body.trim()) parts.push(body)
|
|
10
|
+
for (const section of sections) {
|
|
11
|
+
if (section.visible === false) continue
|
|
12
|
+
const chunk = extractSectionContentHtml(section)
|
|
13
|
+
if (chunk) parts.push(chunk)
|
|
14
|
+
}
|
|
15
|
+
return parts.join('\n')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Live 0–100 SEO score from the current in-editor document state. */
|
|
19
|
+
export function computeLiveSeoScore(input: {
|
|
20
|
+
title: string
|
|
21
|
+
slug: string
|
|
22
|
+
seoTitle: string
|
|
23
|
+
seoDescription: string
|
|
24
|
+
focusKeyword?: string
|
|
25
|
+
sections: PageSection[]
|
|
26
|
+
bodyHtml?: string
|
|
27
|
+
}): number {
|
|
28
|
+
const content = buildEditorContentHtml(input.sections, input.bodyHtml ?? '')
|
|
29
|
+
return analyzeContent({
|
|
30
|
+
title: input.title,
|
|
31
|
+
slug: input.slug,
|
|
32
|
+
content,
|
|
33
|
+
metaTitle: input.seoTitle,
|
|
34
|
+
metaDescription: input.seoDescription,
|
|
35
|
+
focusKeyphrase: input.focusKeyword,
|
|
36
|
+
}).score
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractSectionContentHtml(section: PageSection): string {
|
|
40
|
+
const content = section.content
|
|
41
|
+
if (!content || typeof content !== 'object') return ''
|
|
42
|
+
const def = getSectionType(section.sectionType)
|
|
43
|
+
const chunks: string[] = []
|
|
44
|
+
|
|
45
|
+
if (def) {
|
|
46
|
+
for (const field of def.fields) {
|
|
47
|
+
const val = content[field.key]
|
|
48
|
+
if (typeof val !== 'string' || !val.trim()) continue
|
|
49
|
+
if (field.type === 'richText' || HTML_LIKE.test(val)) {
|
|
50
|
+
chunks.push(val)
|
|
51
|
+
} else if (/heading|title|subtitle|eyebrow/i.test(field.key)) {
|
|
52
|
+
chunks.push(`<h2>${escapeHtml(val.trim())}</h2>`)
|
|
53
|
+
} else if (/body|content|text|copy|description|excerpt|quote|answer/i.test(field.key)) {
|
|
54
|
+
chunks.push(`<p>${escapeHtml(val.trim())}</p>`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
collectStringValues(content, chunks, 0)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return chunks.join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function collectStringValues(obj: Record<string, unknown>, chunks: string[], depth: number): void {
|
|
65
|
+
if (depth > 6) return
|
|
66
|
+
for (const value of Object.values(obj)) {
|
|
67
|
+
if (typeof value === 'string' && value.trim()) {
|
|
68
|
+
chunks.push(HTML_LIKE.test(value) ? value : `<p>${escapeHtml(value.trim())}</p>`)
|
|
69
|
+
} else if (Array.isArray(value)) {
|
|
70
|
+
for (const item of value) {
|
|
71
|
+
if (item && typeof item === 'object') {
|
|
72
|
+
collectStringValues(item as Record<string, unknown>, chunks, depth + 1)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} else if (value && typeof value === 'object') {
|
|
76
|
+
collectStringValues(value as Record<string, unknown>, chunks, depth + 1)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function escapeHtml(text: string): string {
|
|
82
|
+
return text
|
|
83
|
+
.replace(/&/g, '&')
|
|
84
|
+
.replace(/</g, '<')
|
|
85
|
+
.replace(/>/g, '>')
|
|
86
|
+
.replace(/"/g, '"')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const STOP_WORDS = new Set([
|
|
90
|
+
'about',
|
|
91
|
+
'after',
|
|
92
|
+
'also',
|
|
93
|
+
'been',
|
|
94
|
+
'before',
|
|
95
|
+
'being',
|
|
96
|
+
'between',
|
|
97
|
+
'both',
|
|
98
|
+
'could',
|
|
99
|
+
'each',
|
|
100
|
+
'from',
|
|
101
|
+
'have',
|
|
102
|
+
'into',
|
|
103
|
+
'more',
|
|
104
|
+
'most',
|
|
105
|
+
'other',
|
|
106
|
+
'over',
|
|
107
|
+
'some',
|
|
108
|
+
'such',
|
|
109
|
+
'than',
|
|
110
|
+
'that',
|
|
111
|
+
'their',
|
|
112
|
+
'them',
|
|
113
|
+
'then',
|
|
114
|
+
'there',
|
|
115
|
+
'these',
|
|
116
|
+
'they',
|
|
117
|
+
'this',
|
|
118
|
+
'through',
|
|
119
|
+
'under',
|
|
120
|
+
'very',
|
|
121
|
+
'were',
|
|
122
|
+
'what',
|
|
123
|
+
'when',
|
|
124
|
+
'where',
|
|
125
|
+
'which',
|
|
126
|
+
'while',
|
|
127
|
+
'with',
|
|
128
|
+
'would',
|
|
129
|
+
'your',
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
/** Top content words for the Insights accordion (excluding common stop words). */
|
|
133
|
+
export function prominentWordsFromText(plainText: string, limit = 8): string[] {
|
|
134
|
+
const counts = new Map<string, number>()
|
|
135
|
+
for (const raw of plainText.toLowerCase().split(/\s+/)) {
|
|
136
|
+
const word = raw.replace(/[^a-z0-9'-]/g, '')
|
|
137
|
+
if (word.length < 4 || STOP_WORDS.has(word)) continue
|
|
138
|
+
counts.set(word, (counts.get(word) ?? 0) + 1)
|
|
139
|
+
}
|
|
140
|
+
return [...counts.entries()]
|
|
141
|
+
.sort((a, b) => b[1] - a[1])
|
|
142
|
+
.slice(0, limit)
|
|
143
|
+
.map(([word]) => word)
|
|
144
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getAdminRootElement } from './admin-root-element.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Admin theme tokens are scoped under `.actuate-admin` (styles/theme.css),
|
|
3
5
|
* and dark mode / accent / density are applied as classes-attributes on that
|
|
@@ -9,6 +11,5 @@
|
|
|
9
11
|
* Returns `undefined` during SSR (portals only materialize client-side).
|
|
10
12
|
*/
|
|
11
13
|
export function adminPortalContainer(): HTMLElement | undefined {
|
|
12
|
-
|
|
13
|
-
return document.querySelector<HTMLElement>('.actuate-admin') ?? undefined
|
|
14
|
+
return getAdminRootElement() ?? undefined
|
|
14
15
|
}
|