@actuate-media/cms-admin 0.74.0 → 0.76.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 +18 -0
- package/dist/__tests__/lib/post-editor-service.test.js +2 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +12 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.js +5 -6
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/EditorSeoAside.d.ts +7 -4
- package/dist/components/EditorSeoAside.d.ts.map +1 -1
- package/dist/components/EditorSeoAside.js +7 -16
- package/dist/components/EditorSeoAside.js.map +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 +131 -31
- 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/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/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/page-editor-service.d.ts +2 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +4 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +3 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +4 -0
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +2 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +5 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +9 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +38 -6
- 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 +47 -2
- 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/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/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +48 -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/package.json +3 -3
- package/src/__tests__/lib/post-editor-service.test.ts +2 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +24 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +5 -9
- package/src/__tests__/views/seo-content-pane.render.test.tsx +2 -0
- package/src/components/EditorSeoAside.tsx +27 -108
- 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 +497 -244
- package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
- package/src/components/seo/SeoLinkingSection.tsx +151 -0
- package/src/lib/editor-seo-content.ts +144 -0
- package/src/lib/page-editor-service.ts +8 -0
- package/src/lib/post-editor-service.ts +8 -0
- package/src/lib/seo-service.ts +7 -0
- package/src/views/page-editor/EditorTopBar.tsx +76 -4
- package/src/views/page-editor/PageSectionEditor.tsx +70 -0
- package/src/views/page-editor/PageSettingsModal.tsx +1 -45
- package/src/views/post-editor/PostFieldsModal.tsx +19 -138
- package/src/views/post-editor/PostSectionEditor.tsx +72 -0
- package/src/views/seo/ContentTab.tsx +11 -5
|
@@ -65,8 +65,10 @@ vi.mock('@actuate-media/cms-core/client', async (importOriginal) => {
|
|
|
65
65
|
vi.mock('../../lib/seo-service.js', () => ({
|
|
66
66
|
fetchSeoContentRecords: () => fetchSeoContentRecords(),
|
|
67
67
|
fetchSeoContentRecord: (t: string, i: string) => fetchSeoContentRecord(t, i),
|
|
68
|
+
fetchDocumentSeoAnalysis: vi.fn(async () => ({ score: 72, checks: [] })),
|
|
68
69
|
updateSeoFields: (t: string, i: string, p: unknown) => updateSeoFields(t, i, p),
|
|
69
70
|
generateSeoField: vi.fn(async () => ({ unavailable: true })),
|
|
71
|
+
fetchLinkSuggestions: vi.fn(async () => ({ data: { suggestions: [] } })),
|
|
70
72
|
SEO_SCHEMA_TYPES: ['Article', 'WebPage'],
|
|
71
73
|
applyContentSeoQuery: (
|
|
72
74
|
records: Array<{ entityType: string; title: string }>,
|
|
@@ -1,101 +1,48 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect } from 'react'
|
|
4
|
-
import { Search, X } from 'lucide-react'
|
|
5
|
-
import { SeoCharCount } from './seo/SeoCharCount.js'
|
|
3
|
+
import { useEffect, useMemo } from 'react'
|
|
6
4
|
import { SeoEditorPane } from './seo/SeoEditorPane.js'
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const SEO_TITLE_MAX = 60
|
|
11
|
-
const SEO_DESC_MIN = 120
|
|
12
|
-
const SEO_DESC_MAX = 160
|
|
5
|
+
import { ResizableAside } from './ui/ResizableAside.js'
|
|
6
|
+
import { buildEditorContentHtml } from '../lib/editor-seo-content.js'
|
|
7
|
+
import type { PageSection } from '@actuate-media/cms-core/page-sections'
|
|
13
8
|
|
|
14
9
|
const INSPECTOR_SHELL = 'bg-card border-border flex h-full flex-col border-l'
|
|
15
|
-
const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
16
|
-
const INPUT =
|
|
17
|
-
'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
18
10
|
|
|
19
11
|
export interface EditorSeoAsideProps {
|
|
20
12
|
entityType: 'page' | 'post'
|
|
21
|
-
/** Saved document id — when absent,
|
|
13
|
+
/** Saved document id — when absent, draft mode with live analysis. */
|
|
22
14
|
entityId: string | null
|
|
23
15
|
title: string
|
|
16
|
+
slug: string
|
|
24
17
|
seoTitle: string
|
|
25
18
|
seoDescription: string
|
|
19
|
+
/** Post body HTML and/or section content for live SEO analysis. */
|
|
20
|
+
sections?: PageSection[]
|
|
21
|
+
bodyHtml?: string
|
|
26
22
|
canEdit: boolean
|
|
27
23
|
onClose: () => void
|
|
28
|
-
/** Sync basic meta fields on the in-memory editor model (draft flow). */
|
|
29
24
|
onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
|
|
30
|
-
/** Refetch the editor document after SEO API save (saved flow). */
|
|
31
25
|
onSaved?: () => void
|
|
32
26
|
}
|
|
33
27
|
|
|
34
|
-
function DraftSeoForm({
|
|
35
|
-
title,
|
|
36
|
-
seoTitle,
|
|
37
|
-
seoDescription,
|
|
38
|
-
canEdit,
|
|
39
|
-
onDraftChange,
|
|
40
|
-
}: Pick<
|
|
41
|
-
EditorSeoAsideProps,
|
|
42
|
-
'title' | 'seoTitle' | 'seoDescription' | 'canEdit' | 'onDraftChange'
|
|
43
|
-
>) {
|
|
44
|
-
return (
|
|
45
|
-
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
|
46
|
-
<p className="border-border bg-muted text-muted-foreground rounded-md border p-4 text-sm">
|
|
47
|
-
Save a draft first to unlock the full SEO editor — focus keyword, social previews, schema,
|
|
48
|
-
robots, and AI assists. You can still set a basic title and description below; they are
|
|
49
|
-
saved with the document.
|
|
50
|
-
</p>
|
|
51
|
-
<div>
|
|
52
|
-
<div className="mb-1 flex items-center justify-between">
|
|
53
|
-
<label className={`${LABEL} mb-0`} htmlFor="editor-seo-title">
|
|
54
|
-
SEO title
|
|
55
|
-
</label>
|
|
56
|
-
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
57
|
-
</div>
|
|
58
|
-
<input
|
|
59
|
-
id="editor-seo-title"
|
|
60
|
-
className={INPUT}
|
|
61
|
-
value={seoTitle}
|
|
62
|
-
disabled={!canEdit}
|
|
63
|
-
placeholder={title || 'Defaults to the page title'}
|
|
64
|
-
onChange={(e) => onDraftChange?.({ seoTitle: e.target.value })}
|
|
65
|
-
/>
|
|
66
|
-
</div>
|
|
67
|
-
<div>
|
|
68
|
-
<div className="mb-1 flex items-center justify-between">
|
|
69
|
-
<label className={`${LABEL} mb-0`} htmlFor="editor-seo-desc">
|
|
70
|
-
SEO description
|
|
71
|
-
</label>
|
|
72
|
-
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
73
|
-
</div>
|
|
74
|
-
<textarea
|
|
75
|
-
id="editor-seo-desc"
|
|
76
|
-
className={`${INPUT} min-h-[96px] resize-y`}
|
|
77
|
-
value={seoDescription}
|
|
78
|
-
disabled={!canEdit}
|
|
79
|
-
placeholder="Summarize this content for search results (120–160 characters)"
|
|
80
|
-
onChange={(e) => onDraftChange?.({ seoDescription: e.target.value })}
|
|
81
|
-
/>
|
|
82
|
-
</div>
|
|
83
|
-
</div>
|
|
84
|
-
)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
28
|
/** Right-side SEO panel for the Page / Post section editors. */
|
|
88
29
|
export function EditorSeoAside({
|
|
89
30
|
entityType,
|
|
90
31
|
entityId,
|
|
91
|
-
|
|
32
|
+
slug,
|
|
92
33
|
seoTitle,
|
|
93
34
|
seoDescription,
|
|
94
|
-
|
|
35
|
+
sections = [],
|
|
36
|
+
bodyHtml = '',
|
|
95
37
|
onClose,
|
|
96
38
|
onDraftChange,
|
|
97
39
|
onSaved,
|
|
98
40
|
}: EditorSeoAsideProps) {
|
|
41
|
+
const contentHtml = useMemo(
|
|
42
|
+
() => buildEditorContentHtml(sections, bodyHtml),
|
|
43
|
+
[sections, bodyHtml],
|
|
44
|
+
)
|
|
45
|
+
|
|
99
46
|
useEffect(() => {
|
|
100
47
|
function onKey(e: KeyboardEvent) {
|
|
101
48
|
if (e.key === 'Escape') onClose()
|
|
@@ -106,44 +53,16 @@ export function EditorSeoAside({
|
|
|
106
53
|
|
|
107
54
|
return (
|
|
108
55
|
<ResizableAside role="dialog" aria-label="SEO settings" className={INSPECTOR_SHELL}>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<div className="flex min-w-0 items-center gap-2">
|
|
120
|
-
<Search className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
|
|
121
|
-
<div className="min-w-0">
|
|
122
|
-
<h2 className="text-foreground truncate text-sm font-medium">SEO</h2>
|
|
123
|
-
<p className="text-muted-foreground truncate text-xs">{title || 'Untitled'}</p>
|
|
124
|
-
</div>
|
|
125
|
-
</div>
|
|
126
|
-
<div className="flex shrink-0 items-center gap-0.5">
|
|
127
|
-
<InspectorExpandButton />
|
|
128
|
-
<button
|
|
129
|
-
type="button"
|
|
130
|
-
onClick={onClose}
|
|
131
|
-
aria-label="Close SEO panel"
|
|
132
|
-
className="text-muted-foreground hover:text-foreground hover:bg-accent flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
|
|
133
|
-
>
|
|
134
|
-
<X className="h-4 w-4" aria-hidden />
|
|
135
|
-
</button>
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
<DraftSeoForm
|
|
139
|
-
title={title}
|
|
140
|
-
seoTitle={seoTitle}
|
|
141
|
-
seoDescription={seoDescription}
|
|
142
|
-
canEdit={canEdit}
|
|
143
|
-
onDraftChange={onDraftChange}
|
|
144
|
-
/>
|
|
145
|
-
</>
|
|
146
|
-
)}
|
|
56
|
+
<SeoEditorPane
|
|
57
|
+
entityType={entityType}
|
|
58
|
+
entityId={entityId}
|
|
59
|
+
slug={slug}
|
|
60
|
+
contentHtml={contentHtml}
|
|
61
|
+
draftMeta={{ seoTitle, seoDescription }}
|
|
62
|
+
onDraftChange={onDraftChange}
|
|
63
|
+
onClose={onClose}
|
|
64
|
+
onSaved={onSaved}
|
|
65
|
+
/>
|
|
147
66
|
</ResizableAside>
|
|
148
67
|
)
|
|
149
68
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { gradeFromScore, gradeMeta } from './primitives.js'
|
|
4
|
+
|
|
5
|
+
/** Compact color-coded score for the page/post editor toolbar (`77/100` left of "SEO"). */
|
|
6
|
+
export function EditorSeoScoreBadge({
|
|
7
|
+
score,
|
|
8
|
+
inverted = false,
|
|
9
|
+
}: {
|
|
10
|
+
score: number
|
|
11
|
+
/** When the SEO toggle uses `bg-primary`, render score in primary-foreground. */
|
|
12
|
+
inverted?: boolean
|
|
13
|
+
}) {
|
|
14
|
+
const meta = gradeMeta(gradeFromScore(score))
|
|
15
|
+
return (
|
|
16
|
+
<span
|
|
17
|
+
className={`inline-flex items-baseline gap-0.5 text-xs font-medium tabular-nums ${
|
|
18
|
+
inverted ? 'text-primary-foreground' : meta.text
|
|
19
|
+
}`}
|
|
20
|
+
aria-hidden
|
|
21
|
+
>
|
|
22
|
+
{score}
|
|
23
|
+
<span className={inverted ? 'text-primary-foreground/80' : 'opacity-70'}>/100</span>
|
|
24
|
+
</span>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import * as Accordion from '@radix-ui/react-accordion'
|
|
5
|
+
import { ChevronDown } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
export function SeoAccordion({
|
|
8
|
+
children,
|
|
9
|
+
defaultOpen = ['focus-keyword', 'search-appearance'],
|
|
10
|
+
}: {
|
|
11
|
+
children: ReactNode
|
|
12
|
+
defaultOpen?: string[]
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<Accordion.Root
|
|
16
|
+
type="multiple"
|
|
17
|
+
defaultValue={defaultOpen}
|
|
18
|
+
className="divide-y divide-[var(--bdr)]"
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</Accordion.Root>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SeoAccordionItem({
|
|
26
|
+
value,
|
|
27
|
+
title,
|
|
28
|
+
badge,
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
value: string
|
|
32
|
+
title: string
|
|
33
|
+
badge?: ReactNode
|
|
34
|
+
children: ReactNode
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<Accordion.Item value={value} className="border-[var(--bdr)] bg-[var(--sb)]">
|
|
38
|
+
<Accordion.Header>
|
|
39
|
+
<Accordion.Trigger className="group flex w-full items-center justify-between gap-2 px-5 py-3.5 text-left transition-colors duration-[var(--motion-fast)] hover:bg-[var(--bg)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none">
|
|
40
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
41
|
+
<span className="text-[13px] font-medium text-[var(--txt)]">{title}</span>
|
|
42
|
+
{badge}
|
|
43
|
+
</span>
|
|
44
|
+
<ChevronDown
|
|
45
|
+
size={16}
|
|
46
|
+
aria-hidden
|
|
47
|
+
className="shrink-0 text-[var(--muted)] group-data-[state=open]:rotate-180 motion-safe:transition-transform motion-safe:duration-200"
|
|
48
|
+
/>
|
|
49
|
+
</Accordion.Trigger>
|
|
50
|
+
</Accordion.Header>
|
|
51
|
+
<Accordion.Content className="overflow-hidden data-[state=closed]:hidden">
|
|
52
|
+
<div className="space-y-3 px-5 pt-1 pb-4">{children}</div>
|
|
53
|
+
</Accordion.Content>
|
|
54
|
+
</Accordion.Item>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function SeoAccordionBadge({
|
|
59
|
+
tone,
|
|
60
|
+
children,
|
|
61
|
+
}: {
|
|
62
|
+
tone: 'neutral' | 'good' | 'warn' | 'bad'
|
|
63
|
+
children: ReactNode
|
|
64
|
+
}) {
|
|
65
|
+
const cls =
|
|
66
|
+
tone === 'good'
|
|
67
|
+
? 'bg-[var(--suc-l)] text-[var(--suc)]'
|
|
68
|
+
: tone === 'warn'
|
|
69
|
+
? 'bg-[var(--warn-l)] text-[var(--warn)]'
|
|
70
|
+
: tone === 'bad'
|
|
71
|
+
? 'bg-[var(--err-l)] text-[var(--err)]'
|
|
72
|
+
: 'bg-[var(--bg)] text-[var(--sub)]'
|
|
73
|
+
return (
|
|
74
|
+
<span className={`rounded-full px-2 py-0.5 text-[10.5px] font-medium ${cls}`}>{children}</span>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AlertCircle, CheckCircle2, Sparkles, Wand2 } from 'lucide-react'
|
|
4
|
+
import type { SEOCheck } from '@actuate-media/cms-core/seo/analysis'
|
|
5
|
+
|
|
6
|
+
export function SeoAnalysisList({
|
|
7
|
+
checks,
|
|
8
|
+
onAiFixField,
|
|
9
|
+
aiFixField,
|
|
10
|
+
}: {
|
|
11
|
+
checks: SEOCheck[]
|
|
12
|
+
onAiFixField?: (field: 'metaTitle' | 'metaDescription' | 'focusKeyword') => void
|
|
13
|
+
aiFixField?: string | null
|
|
14
|
+
}) {
|
|
15
|
+
const problems = checks.filter((c) => c.status === 'problem' || c.status === 'improvement')
|
|
16
|
+
const good = checks.filter((c) => c.status === 'good')
|
|
17
|
+
|
|
18
|
+
if (checks.length === 0) {
|
|
19
|
+
return (
|
|
20
|
+
<p className="text-[12px] text-[var(--muted)]">
|
|
21
|
+
Add content and a focus keyphrase to run SEO checks.
|
|
22
|
+
</p>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="space-y-4">
|
|
28
|
+
{problems.length > 0 && (
|
|
29
|
+
<div>
|
|
30
|
+
<h4 className="mb-2 text-[11px] font-semibold tracking-wide text-[var(--sub)] uppercase">
|
|
31
|
+
Problems ({problems.length})
|
|
32
|
+
</h4>
|
|
33
|
+
<ul className="space-y-2">
|
|
34
|
+
{problems.map((check) => (
|
|
35
|
+
<AnalysisRow
|
|
36
|
+
key={check.id}
|
|
37
|
+
check={check}
|
|
38
|
+
onAiFix={onAiFixField}
|
|
39
|
+
aiBusy={aiFixField}
|
|
40
|
+
/>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
{good.length > 0 && (
|
|
46
|
+
<div>
|
|
47
|
+
<h4 className="mb-2 text-[11px] font-semibold tracking-wide text-[var(--sub)] uppercase">
|
|
48
|
+
Good results ({good.length})
|
|
49
|
+
</h4>
|
|
50
|
+
<ul className="space-y-2">
|
|
51
|
+
{good.map((check) => (
|
|
52
|
+
<AnalysisRow key={check.id} check={check} />
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function AnalysisRow({
|
|
62
|
+
check,
|
|
63
|
+
onAiFix,
|
|
64
|
+
aiBusy,
|
|
65
|
+
}: {
|
|
66
|
+
check: SEOCheck
|
|
67
|
+
onAiFix?: (field: 'metaTitle' | 'metaDescription' | 'focusKeyword') => void
|
|
68
|
+
aiBusy?: string | null
|
|
69
|
+
}) {
|
|
70
|
+
const tone =
|
|
71
|
+
check.status === 'good'
|
|
72
|
+
? 'text-[var(--suc)]'
|
|
73
|
+
: check.status === 'improvement'
|
|
74
|
+
? 'text-[var(--warn)]'
|
|
75
|
+
: 'text-[var(--err)]'
|
|
76
|
+
const Icon = check.status === 'good' ? CheckCircle2 : AlertCircle
|
|
77
|
+
const aiField = aiFieldForCheck(check.id)
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<li className="flex items-start gap-2 rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-2">
|
|
81
|
+
<Icon className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${tone}`} aria-hidden />
|
|
82
|
+
<div className="min-w-0 flex-1">
|
|
83
|
+
<div className="text-[12.5px] font-medium text-[var(--txt)]">{check.title}</div>
|
|
84
|
+
<p className="text-[11.5px] text-[var(--sub)]">{check.description}</p>
|
|
85
|
+
</div>
|
|
86
|
+
{onAiFix && aiField && check.status !== 'good' && (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
aria-label={`AI help for ${check.title}`}
|
|
90
|
+
disabled={Boolean(aiBusy)}
|
|
91
|
+
onClick={() => onAiFix(aiField)}
|
|
92
|
+
className="inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-[var(--acc)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-60"
|
|
93
|
+
>
|
|
94
|
+
{aiBusy === aiField ? (
|
|
95
|
+
<Sparkles className="h-3 w-3 animate-pulse" aria-hidden />
|
|
96
|
+
) : (
|
|
97
|
+
<Wand2 className="h-3 w-3" aria-hidden />
|
|
98
|
+
)}
|
|
99
|
+
AI
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
</li>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function aiFieldForCheck(checkId: string): 'metaTitle' | 'metaDescription' | 'focusKeyword' | null {
|
|
107
|
+
if (checkId.includes('meta-title') || checkId.includes('keyphrase-in-title')) return 'metaTitle'
|
|
108
|
+
if (checkId.includes('meta-description') || checkId.includes('keyphrase-in-meta-description')) {
|
|
109
|
+
return 'metaDescription'
|
|
110
|
+
}
|
|
111
|
+
if (checkId.includes('keyphrase')) return 'focusKeyword'
|
|
112
|
+
return null
|
|
113
|
+
}
|