@actuate-media/cms-admin 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +9 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/seo/Drawer.d.ts +11 -0
- package/dist/components/seo/Drawer.d.ts.map +1 -0
- package/dist/components/seo/Drawer.js +13 -0
- package/dist/components/seo/Drawer.js.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.js +94 -0
- package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.js +34 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.js +111 -0
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
- package/dist/components/seo/primitives.d.ts +53 -0
- package/dist/components/seo/primitives.d.ts.map +1 -0
- package/dist/components/seo/primitives.js +100 -0
- package/dist/components/seo/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -0
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/seo-service.d.ts +322 -0
- package/dist/lib/seo-service.d.ts.map +1 -0
- package/dist/lib/seo-service.js +295 -0
- package/dist/lib/seo-service.js.map +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +4 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +36 -25
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
- package/dist/views/Posts/post-type-presets.d.ts +5 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
- package/dist/views/Posts/post-type-presets.js +10 -64
- package/dist/views/Posts/post-type-presets.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +89 -132
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts +6 -0
- package/dist/views/seo/AuditTab.d.ts.map +1 -0
- package/dist/views/seo/AuditTab.js +57 -0
- package/dist/views/seo/AuditTab.js.map +1 -0
- package/dist/views/seo/ContentTab.d.ts +5 -0
- package/dist/views/seo/ContentTab.d.ts.map +1 -0
- package/dist/views/seo/ContentTab.js +27 -0
- package/dist/views/seo/ContentTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.d.ts +8 -0
- package/dist/views/seo/OverviewTab.d.ts.map +1 -0
- package/dist/views/seo/OverviewTab.js +35 -0
- package/dist/views/seo/OverviewTab.js.map +1 -0
- package/dist/views/seo/RedirectsTab.d.ts +2 -0
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
- package/dist/views/seo/RedirectsTab.js +124 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -0
- package/dist/views/seo/TechnicalTab.d.ts +2 -0
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
- package/dist/views/seo/TechnicalTab.js +102 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -0
- package/dist/views/seo/useSeoResource.d.ts +8 -0
- package/dist/views/seo/useSeoResource.d.ts.map +1 -0
- package/dist/views/seo/useSeoResource.js +42 -0
- package/dist/views/seo/useSeoResource.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +9 -5
- package/src/components/seo/Drawer.tsx +61 -0
- package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
- package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
- package/src/components/seo/SeoEditorDrawer.tsx +347 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
- package/src/components/seo/primitives.tsx +237 -0
- package/src/layout/Sidebar.tsx +3 -0
- package/src/lib/seo-service.ts +623 -0
- package/src/router/index.ts +4 -1
- package/src/views/MediaBrowser.tsx +89 -67
- package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- package/src/views/seo/AuditTab.tsx +229 -0
- package/src/views/seo/ContentTab.tsx +164 -0
- package/src/views/seo/OverviewTab.tsx +257 -0
- package/src/views/seo/RedirectsTab.tsx +392 -0
- package/src/views/seo/TechnicalTab.tsx +303 -0
- package/src/views/seo/useSeoResource.ts +52 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { ClipboardCheck, Clock, CheckCircle2 } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
fetchSeoIssues,
|
|
7
|
+
fetchSeoAuditRuns,
|
|
8
|
+
type SeoIssue,
|
|
9
|
+
type SeoIssueQuery,
|
|
10
|
+
type SeoSeverity,
|
|
11
|
+
type SeoIssueStatus,
|
|
12
|
+
} from '../../lib/seo-service.js'
|
|
13
|
+
import {
|
|
14
|
+
SectionCard,
|
|
15
|
+
SeoLoading,
|
|
16
|
+
SeoEmptyState,
|
|
17
|
+
SeoErrorState,
|
|
18
|
+
severityMeta,
|
|
19
|
+
SeoStatusBadge,
|
|
20
|
+
} from '../../components/seo/primitives.js'
|
|
21
|
+
import { SeoIssueFixDrawer } from '../../components/seo/SeoIssueFixDrawer.js'
|
|
22
|
+
import { useSeoResource } from './useSeoResource.js'
|
|
23
|
+
|
|
24
|
+
const SEVERITY_FILTERS: { id: SeoSeverity | 'all'; label: string }[] = [
|
|
25
|
+
{ id: 'all', label: 'All' },
|
|
26
|
+
{ id: 'critical', label: 'Critical' },
|
|
27
|
+
{ id: 'warning', label: 'Warnings' },
|
|
28
|
+
{ id: 'info', label: 'Info' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const STATUS_FILTERS: { id: SeoIssueStatus | 'all'; label: string }[] = [
|
|
32
|
+
{ id: 'open', label: 'Open' },
|
|
33
|
+
{ id: 'recurring', label: 'Recurring' },
|
|
34
|
+
{ id: 'ignored', label: 'Ignored' },
|
|
35
|
+
{ id: 'resolved', label: 'Resolved' },
|
|
36
|
+
{ id: 'all', label: 'All' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
function issueStatusTone(status: SeoIssueStatus) {
|
|
40
|
+
switch (status) {
|
|
41
|
+
case 'resolved':
|
|
42
|
+
return 'good' as const
|
|
43
|
+
case 'ignored':
|
|
44
|
+
return 'neutral' as const
|
|
45
|
+
case 'recurring':
|
|
46
|
+
return 'fair' as const
|
|
47
|
+
default:
|
|
48
|
+
return 'critical' as const
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function AuditSummaryCards({ refetchKey }: { refetchKey: number }) {
|
|
53
|
+
const { data, loading } = useSeoResource(fetchSeoAuditRuns, [refetchKey])
|
|
54
|
+
const last = (data ?? [])[0]
|
|
55
|
+
return (
|
|
56
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
57
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
58
|
+
<p className="text-muted-foreground flex items-center gap-1.5 text-sm">
|
|
59
|
+
<Clock className="h-3.5 w-3.5" aria-hidden /> Last audit
|
|
60
|
+
</p>
|
|
61
|
+
<p className="text-foreground mt-1 text-sm font-medium">
|
|
62
|
+
{loading ? '…' : last ? new Date(last.startedAt).toLocaleString() : 'Never'}
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
66
|
+
<p className="text-muted-foreground text-sm">Pages scanned</p>
|
|
67
|
+
<p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
|
|
68
|
+
{last?.pagesScanned ?? 0}
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
72
|
+
<p className="text-muted-foreground text-sm">Issues found</p>
|
|
73
|
+
<p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
|
|
74
|
+
{last?.issuesFound ?? 0}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
78
|
+
<p className="text-muted-foreground text-sm">Site score</p>
|
|
79
|
+
<p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
|
|
80
|
+
{last?.siteScoreAfter ?? '—'}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function AuditTab({
|
|
88
|
+
refetchKey = 0,
|
|
89
|
+
initialIssueId,
|
|
90
|
+
onNavigate,
|
|
91
|
+
}: {
|
|
92
|
+
refetchKey?: number
|
|
93
|
+
initialIssueId?: string | null
|
|
94
|
+
onNavigate?: (path: string) => void
|
|
95
|
+
}) {
|
|
96
|
+
const [severity, setSeverity] = useState<SeoSeverity | 'all'>('all')
|
|
97
|
+
const [status, setStatus] = useState<SeoIssueStatus | 'all'>('open')
|
|
98
|
+
const [openIssue, setOpenIssue] = useState<string | null>(initialIssueId ?? null)
|
|
99
|
+
|
|
100
|
+
const query: SeoIssueQuery = useMemo(() => ({ severity, status }), [severity, status])
|
|
101
|
+
const { data, loading, error, refetch } = useSeoResource(
|
|
102
|
+
() => fetchSeoIssues(query),
|
|
103
|
+
[severity, status, refetchKey],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const issues = data ?? []
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="space-y-4">
|
|
110
|
+
<AuditSummaryCards refetchKey={refetchKey} />
|
|
111
|
+
|
|
112
|
+
<SectionCard>
|
|
113
|
+
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
114
|
+
<div className="flex flex-wrap gap-1" role="tablist" aria-label="Filter by severity">
|
|
115
|
+
{SEVERITY_FILTERS.map((f) => (
|
|
116
|
+
<button
|
|
117
|
+
key={f.id}
|
|
118
|
+
role="tab"
|
|
119
|
+
aria-selected={severity === f.id}
|
|
120
|
+
onClick={() => setSeverity(f.id)}
|
|
121
|
+
className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
|
|
122
|
+
severity === f.id
|
|
123
|
+
? 'bg-primary text-primary-foreground'
|
|
124
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
125
|
+
}`}
|
|
126
|
+
>
|
|
127
|
+
{f.label}
|
|
128
|
+
</button>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
<div className="flex flex-wrap gap-1" role="tablist" aria-label="Filter by status">
|
|
132
|
+
{STATUS_FILTERS.map((f) => (
|
|
133
|
+
<button
|
|
134
|
+
key={f.id}
|
|
135
|
+
role="tab"
|
|
136
|
+
aria-selected={status === f.id}
|
|
137
|
+
onClick={() => setStatus(f.id)}
|
|
138
|
+
className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
|
|
139
|
+
status === f.id
|
|
140
|
+
? 'bg-accent text-foreground'
|
|
141
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
142
|
+
}`}
|
|
143
|
+
>
|
|
144
|
+
{f.label}
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{loading ? (
|
|
151
|
+
<SeoLoading label="Loading issues…" />
|
|
152
|
+
) : error ? (
|
|
153
|
+
<SeoErrorState message={error} onRetry={refetch} />
|
|
154
|
+
) : issues.length === 0 ? (
|
|
155
|
+
<SeoEmptyState
|
|
156
|
+
icon={status === 'open' ? <CheckCircle2 size={24} /> : <ClipboardCheck size={24} />}
|
|
157
|
+
title={status === 'open' ? 'No open issues' : 'No issues match'}
|
|
158
|
+
description={
|
|
159
|
+
status === 'open'
|
|
160
|
+
? 'Run an audit to scan for new issues, or adjust the filters.'
|
|
161
|
+
: 'Try a different severity or status filter.'
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
) : (
|
|
165
|
+
<div className="overflow-x-auto">
|
|
166
|
+
<table className="w-full text-sm" aria-label="SEO issues">
|
|
167
|
+
<thead>
|
|
168
|
+
<tr className="text-muted-foreground border-border border-b text-left">
|
|
169
|
+
<th className="py-2 pr-4 font-medium">Severity</th>
|
|
170
|
+
<th className="py-2 pr-4 font-medium">Issue</th>
|
|
171
|
+
<th className="py-2 pr-4 font-medium">Page</th>
|
|
172
|
+
<th className="py-2 pr-4 font-medium">Status</th>
|
|
173
|
+
<th className="py-2 text-right font-medium">
|
|
174
|
+
<span className="sr-only">Actions</span>
|
|
175
|
+
</th>
|
|
176
|
+
</tr>
|
|
177
|
+
</thead>
|
|
178
|
+
<tbody className="divide-border divide-y">
|
|
179
|
+
{issues.map((issue: SeoIssue) => {
|
|
180
|
+
const meta = severityMeta(issue.severity)
|
|
181
|
+
return (
|
|
182
|
+
<tr key={issue.id} className="hover:bg-accent/40">
|
|
183
|
+
<td className="py-2.5 pr-4">
|
|
184
|
+
<span className={`inline-flex items-center gap-1.5 text-sm ${meta.text}`}>
|
|
185
|
+
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
|
|
186
|
+
{meta.label}
|
|
187
|
+
</span>
|
|
188
|
+
</td>
|
|
189
|
+
<td className="py-2.5 pr-4">
|
|
190
|
+
<span className="text-foreground block font-medium">{issue.title}</span>
|
|
191
|
+
<span className="text-muted-foreground block capitalize">
|
|
192
|
+
{issue.category}
|
|
193
|
+
</span>
|
|
194
|
+
</td>
|
|
195
|
+
<td className="text-muted-foreground py-2.5 pr-4">
|
|
196
|
+
<span className="block truncate">
|
|
197
|
+
{issue.entityTitle || issue.url || '—'}
|
|
198
|
+
</span>
|
|
199
|
+
</td>
|
|
200
|
+
<td className="py-2.5 pr-4">
|
|
201
|
+
<SeoStatusBadge label={issue.status} tone={issueStatusTone(issue.status)} />
|
|
202
|
+
</td>
|
|
203
|
+
<td className="py-2.5 text-right">
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => setOpenIssue(issue.id)}
|
|
206
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
207
|
+
>
|
|
208
|
+
{issue.fixable ? 'Fix' : 'View'}
|
|
209
|
+
</button>
|
|
210
|
+
</td>
|
|
211
|
+
</tr>
|
|
212
|
+
)
|
|
213
|
+
})}
|
|
214
|
+
</tbody>
|
|
215
|
+
</table>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</SectionCard>
|
|
219
|
+
|
|
220
|
+
<SeoIssueFixDrawer
|
|
221
|
+
open={openIssue !== null}
|
|
222
|
+
onOpenChange={(o) => !o && setOpenIssue(null)}
|
|
223
|
+
issueId={openIssue}
|
|
224
|
+
onChanged={refetch}
|
|
225
|
+
onNavigate={onNavigate}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { Search, FileText, Pencil } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
fetchSeoContentRecords,
|
|
7
|
+
applyContentSeoQuery,
|
|
8
|
+
type ContentSeoFilter,
|
|
9
|
+
} from '../../lib/seo-service.js'
|
|
10
|
+
import {
|
|
11
|
+
SectionCard,
|
|
12
|
+
SeoLoading,
|
|
13
|
+
SeoEmptyState,
|
|
14
|
+
SeoErrorState,
|
|
15
|
+
ScorePill,
|
|
16
|
+
SeoStatusBadge,
|
|
17
|
+
} from '../../components/seo/primitives.js'
|
|
18
|
+
import { SeoEditorDrawer } from '../../components/seo/SeoEditorDrawer.js'
|
|
19
|
+
import { useSeoResource } from './useSeoResource.js'
|
|
20
|
+
|
|
21
|
+
const FILTERS: { id: ContentSeoFilter; label: string }[] = [
|
|
22
|
+
{ id: 'all', label: 'All' },
|
|
23
|
+
{ id: 'pages', label: 'Pages' },
|
|
24
|
+
{ id: 'posts', label: 'Posts' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const inputCls =
|
|
28
|
+
'w-full rounded-md border border-border bg-input-background py-2 pl-9 pr-3 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
29
|
+
|
|
30
|
+
export function ContentTab({
|
|
31
|
+
initialEntityType,
|
|
32
|
+
initialEntityId,
|
|
33
|
+
}: {
|
|
34
|
+
initialEntityType?: string | null
|
|
35
|
+
initialEntityId?: string | null
|
|
36
|
+
}) {
|
|
37
|
+
const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
|
|
38
|
+
const [search, setSearch] = useState('')
|
|
39
|
+
const [filter, setFilter] = useState<ContentSeoFilter>('all')
|
|
40
|
+
const [editor, setEditor] = useState<{ type: string; id: string } | null>(
|
|
41
|
+
initialEntityType && initialEntityId ? { type: initialEntityType, id: initialEntityId } : null,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const rows = useMemo(
|
|
45
|
+
() => applyContentSeoQuery(data ?? [], { search, filter }),
|
|
46
|
+
[data, search, filter],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-4">
|
|
51
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
52
|
+
<div className="relative max-w-sm flex-1">
|
|
53
|
+
<Search
|
|
54
|
+
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
55
|
+
aria-hidden
|
|
56
|
+
/>
|
|
57
|
+
<input
|
|
58
|
+
className={inputCls}
|
|
59
|
+
placeholder="Search content…"
|
|
60
|
+
value={search}
|
|
61
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
62
|
+
aria-label="Search content"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="flex gap-1" role="tablist" aria-label="Content type filter">
|
|
66
|
+
{FILTERS.map((f) => (
|
|
67
|
+
<button
|
|
68
|
+
key={f.id}
|
|
69
|
+
role="tab"
|
|
70
|
+
aria-selected={filter === f.id}
|
|
71
|
+
onClick={() => setFilter(f.id)}
|
|
72
|
+
className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
|
|
73
|
+
filter === f.id
|
|
74
|
+
? 'bg-primary text-primary-foreground'
|
|
75
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
{f.label}
|
|
79
|
+
</button>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<SectionCard>
|
|
85
|
+
{loading ? (
|
|
86
|
+
<SeoLoading label="Loading content…" />
|
|
87
|
+
) : error ? (
|
|
88
|
+
<SeoErrorState message={error} onRetry={refetch} />
|
|
89
|
+
) : rows.length === 0 ? (
|
|
90
|
+
<SeoEmptyState
|
|
91
|
+
icon={<FileText size={24} />}
|
|
92
|
+
title={search || filter !== 'all' ? 'No matching content' : 'No content yet'}
|
|
93
|
+
description={
|
|
94
|
+
search || filter !== 'all'
|
|
95
|
+
? 'Try adjusting your search or filter.'
|
|
96
|
+
: 'Publish pages or posts to manage their SEO here.'
|
|
97
|
+
}
|
|
98
|
+
/>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="overflow-x-auto">
|
|
101
|
+
<table className="w-full text-sm" aria-label="Content SEO">
|
|
102
|
+
<thead>
|
|
103
|
+
<tr className="text-muted-foreground border-border border-b text-left">
|
|
104
|
+
<th className="py-2 pr-4 font-medium">Title</th>
|
|
105
|
+
<th className="py-2 pr-4 font-medium">Type</th>
|
|
106
|
+
<th className="py-2 pr-4 font-medium">Meta</th>
|
|
107
|
+
<th className="py-2 pr-4 text-right font-medium">Score</th>
|
|
108
|
+
<th className="py-2 text-right font-medium">
|
|
109
|
+
<span className="sr-only">Actions</span>
|
|
110
|
+
</th>
|
|
111
|
+
</tr>
|
|
112
|
+
</thead>
|
|
113
|
+
<tbody className="divide-border divide-y">
|
|
114
|
+
{rows.map((r) => (
|
|
115
|
+
<tr key={`${r.entityType}-${r.entityId}`} className="hover:bg-accent/40">
|
|
116
|
+
<td className="py-2.5 pr-4">
|
|
117
|
+
<span className="text-foreground block font-medium">{r.title}</span>
|
|
118
|
+
<span className="text-muted-foreground block truncate">{r.url}</span>
|
|
119
|
+
</td>
|
|
120
|
+
<td className="py-2.5 pr-4 capitalize">
|
|
121
|
+
<span className="text-muted-foreground">{r.entityType}</span>
|
|
122
|
+
</td>
|
|
123
|
+
<td className="py-2.5 pr-4">
|
|
124
|
+
<span className="flex flex-wrap gap-1.5">
|
|
125
|
+
<SeoStatusBadge
|
|
126
|
+
label={r.metaTitle ? 'Title' : 'No title'}
|
|
127
|
+
tone={r.metaTitle ? 'good' : 'critical'}
|
|
128
|
+
/>
|
|
129
|
+
<SeoStatusBadge
|
|
130
|
+
label={r.metaDescription ? 'Desc' : 'No desc'}
|
|
131
|
+
tone={r.metaDescription ? 'good' : 'critical'}
|
|
132
|
+
/>
|
|
133
|
+
{r.noindex && <SeoStatusBadge label="No-index" tone="fair" />}
|
|
134
|
+
</span>
|
|
135
|
+
</td>
|
|
136
|
+
<td className="py-2.5 pr-4 text-right">
|
|
137
|
+
<ScorePill score={r.seoScore} />
|
|
138
|
+
</td>
|
|
139
|
+
<td className="py-2.5 text-right">
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => setEditor({ type: r.entityType, id: r.entityId })}
|
|
142
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
143
|
+
>
|
|
144
|
+
<Pencil className="h-3.5 w-3.5" aria-hidden /> Edit
|
|
145
|
+
</button>
|
|
146
|
+
</td>
|
|
147
|
+
</tr>
|
|
148
|
+
))}
|
|
149
|
+
</tbody>
|
|
150
|
+
</table>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</SectionCard>
|
|
154
|
+
|
|
155
|
+
<SeoEditorDrawer
|
|
156
|
+
open={editor !== null}
|
|
157
|
+
onOpenChange={(o) => !o && setEditor(null)}
|
|
158
|
+
entityType={editor?.type ?? null}
|
|
159
|
+
entityId={editor?.id ?? null}
|
|
160
|
+
onSaved={refetch}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
TrendingUp,
|
|
5
|
+
TrendingDown,
|
|
6
|
+
Minus,
|
|
7
|
+
AlertOctagon,
|
|
8
|
+
AlertTriangle,
|
|
9
|
+
Info,
|
|
10
|
+
Zap,
|
|
11
|
+
BarChart3,
|
|
12
|
+
ArrowRight,
|
|
13
|
+
} from 'lucide-react'
|
|
14
|
+
import type { QuickWin, SeoMetric, SeoOverview } from '../../lib/seo-service.js'
|
|
15
|
+
import {
|
|
16
|
+
SectionCard,
|
|
17
|
+
SeoScoreBar,
|
|
18
|
+
SeoLoading,
|
|
19
|
+
SeoEmptyState,
|
|
20
|
+
SeoErrorState,
|
|
21
|
+
gradeMeta,
|
|
22
|
+
btnSecondary,
|
|
23
|
+
} from '../../components/seo/primitives.js'
|
|
24
|
+
import type { SeoResource } from './useSeoResource.js'
|
|
25
|
+
|
|
26
|
+
function TrendIcon({ trend }: { trend?: SeoMetric['trend'] }) {
|
|
27
|
+
if (trend === 'up') return <TrendingUp className="text-success h-4 w-4" aria-hidden />
|
|
28
|
+
if (trend === 'down') return <TrendingDown className="text-destructive h-4 w-4" aria-hidden />
|
|
29
|
+
return <Minus className="text-muted-foreground h-4 w-4" aria-hidden />
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function impactTone(impact: QuickWin['impact']): 'good' | 'fair' | 'poor' {
|
|
33
|
+
return impact === 'High' ? 'poor' : impact === 'Medium' ? 'fair' : 'good'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function OverviewTab({
|
|
37
|
+
resource,
|
|
38
|
+
onOpenIssue,
|
|
39
|
+
onConnectSearchConsole,
|
|
40
|
+
}: {
|
|
41
|
+
resource: SeoResource<SeoOverview>
|
|
42
|
+
onOpenIssue?: (issueId: string) => void
|
|
43
|
+
onConnectSearchConsole?: () => void
|
|
44
|
+
}) {
|
|
45
|
+
const { data, loading, error, refetch } = resource
|
|
46
|
+
|
|
47
|
+
if (loading) return <SeoLoading label="Loading SEO overview…" />
|
|
48
|
+
if (error) return <SeoErrorState message={error} onRetry={refetch} />
|
|
49
|
+
if (!data) return <SeoEmptyState title="No overview data" />
|
|
50
|
+
|
|
51
|
+
const {
|
|
52
|
+
siteScore,
|
|
53
|
+
metrics,
|
|
54
|
+
issuesSummary,
|
|
55
|
+
quickWins,
|
|
56
|
+
searchPerformance,
|
|
57
|
+
searchConsoleConnected,
|
|
58
|
+
} = data
|
|
59
|
+
const grade = gradeMeta(siteScore.grade)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="space-y-6">
|
|
63
|
+
{/* Site score + metrics */}
|
|
64
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
65
|
+
<SectionCard title="Site SEO Score" className="lg:col-span-1">
|
|
66
|
+
<div className="flex items-center gap-4">
|
|
67
|
+
<div className="flex flex-col">
|
|
68
|
+
<span className="text-foreground text-4xl font-medium tabular-nums">
|
|
69
|
+
{siteScore.score}
|
|
70
|
+
</span>
|
|
71
|
+
<span className={`text-sm font-medium ${grade.text}`}>{grade.label}</span>
|
|
72
|
+
</div>
|
|
73
|
+
{siteScore.delta !== null && (
|
|
74
|
+
<span
|
|
75
|
+
className={`inline-flex items-center gap-1 text-sm ${
|
|
76
|
+
siteScore.delta >= 0 ? 'text-success' : 'text-destructive'
|
|
77
|
+
}`}
|
|
78
|
+
>
|
|
79
|
+
{siteScore.delta >= 0 ? (
|
|
80
|
+
<TrendingUp className="h-4 w-4" aria-hidden />
|
|
81
|
+
) : (
|
|
82
|
+
<TrendingDown className="h-4 w-4" aria-hidden />
|
|
83
|
+
)}
|
|
84
|
+
{siteScore.delta >= 0 ? '+' : ''}
|
|
85
|
+
{siteScore.delta} pts
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
<SeoScoreBar score={siteScore.score} className="mt-4" />
|
|
90
|
+
{siteScore.breakdown.length > 0 && (
|
|
91
|
+
<dl className="mt-4 space-y-2">
|
|
92
|
+
{siteScore.breakdown.map((b) => (
|
|
93
|
+
<div key={b.category} className="flex items-center justify-between text-sm">
|
|
94
|
+
<dt className="text-muted-foreground capitalize">{b.category}</dt>
|
|
95
|
+
<dd className="text-foreground tabular-nums">
|
|
96
|
+
{b.score}/{100} <span className="text-muted-foreground">({b.weight}%)</span>
|
|
97
|
+
</dd>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</dl>
|
|
101
|
+
)}
|
|
102
|
+
</SectionCard>
|
|
103
|
+
|
|
104
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:col-span-2">
|
|
105
|
+
{metrics.length === 0 ? (
|
|
106
|
+
<div className="sm:col-span-2">
|
|
107
|
+
<SeoEmptyState
|
|
108
|
+
title="No metrics yet"
|
|
109
|
+
description="Run an audit to populate site metrics."
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
) : (
|
|
113
|
+
metrics.map((m) => (
|
|
114
|
+
<div key={m.label} className="border-border bg-card rounded-lg border p-4">
|
|
115
|
+
<p className="text-muted-foreground text-sm">{m.label}</p>
|
|
116
|
+
<p className="text-foreground mt-1 text-2xl font-medium tabular-nums">{m.value}</p>
|
|
117
|
+
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-sm">
|
|
118
|
+
<TrendIcon trend={m.trend} />
|
|
119
|
+
{m.helper}
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
))
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Issues found + quick wins */}
|
|
128
|
+
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
129
|
+
<SectionCard title="Issues Found">
|
|
130
|
+
<ul className="space-y-3">
|
|
131
|
+
<li className="flex items-center justify-between">
|
|
132
|
+
<span className="text-foreground flex items-center gap-2 text-sm">
|
|
133
|
+
<AlertOctagon className="text-destructive h-4 w-4" aria-hidden /> Critical
|
|
134
|
+
</span>
|
|
135
|
+
<span className="text-foreground font-medium tabular-nums">
|
|
136
|
+
{issuesSummary.critical}
|
|
137
|
+
</span>
|
|
138
|
+
</li>
|
|
139
|
+
<li className="flex items-center justify-between">
|
|
140
|
+
<span className="text-foreground flex items-center gap-2 text-sm">
|
|
141
|
+
<AlertTriangle className="text-warning h-4 w-4" aria-hidden /> Warnings
|
|
142
|
+
</span>
|
|
143
|
+
<span className="text-foreground font-medium tabular-nums">
|
|
144
|
+
{issuesSummary.warning}
|
|
145
|
+
</span>
|
|
146
|
+
</li>
|
|
147
|
+
<li className="flex items-center justify-between">
|
|
148
|
+
<span className="text-foreground flex items-center gap-2 text-sm">
|
|
149
|
+
<Info className="text-muted-foreground h-4 w-4" aria-hidden /> Info
|
|
150
|
+
</span>
|
|
151
|
+
<span className="text-foreground font-medium tabular-nums">{issuesSummary.info}</span>
|
|
152
|
+
</li>
|
|
153
|
+
</ul>
|
|
154
|
+
</SectionCard>
|
|
155
|
+
|
|
156
|
+
<SectionCard title="Quick Wins" description="High-impact fixes ranked by priority">
|
|
157
|
+
{quickWins.length === 0 ? (
|
|
158
|
+
<SeoEmptyState
|
|
159
|
+
icon={<Zap size={24} />}
|
|
160
|
+
title="No quick wins"
|
|
161
|
+
description="Great — no high-impact issues are waiting."
|
|
162
|
+
/>
|
|
163
|
+
) : (
|
|
164
|
+
<ul className="divide-border divide-y">
|
|
165
|
+
{quickWins.map((w) => {
|
|
166
|
+
const tone = impactTone(w.impact)
|
|
167
|
+
const toneCls =
|
|
168
|
+
tone === 'poor'
|
|
169
|
+
? 'bg-destructive/10 text-destructive'
|
|
170
|
+
: tone === 'fair'
|
|
171
|
+
? 'bg-warning/10 text-warning'
|
|
172
|
+
: 'bg-success/10 text-success'
|
|
173
|
+
return (
|
|
174
|
+
<li key={w.id} className="flex items-start justify-between gap-3 py-3 first:pt-0">
|
|
175
|
+
<div className="min-w-0">
|
|
176
|
+
<p className="text-foreground text-sm font-medium">{w.title}</p>
|
|
177
|
+
<p className="text-muted-foreground mt-0.5 text-sm">{w.explanation}</p>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
180
|
+
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${toneCls}`}>
|
|
181
|
+
{w.impact}
|
|
182
|
+
</span>
|
|
183
|
+
{w.issueId && onOpenIssue && (
|
|
184
|
+
<button
|
|
185
|
+
onClick={() => onOpenIssue(w.issueId as string)}
|
|
186
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
187
|
+
>
|
|
188
|
+
Fix <ArrowRight className="h-3.5 w-3.5" aria-hidden />
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</li>
|
|
193
|
+
)
|
|
194
|
+
})}
|
|
195
|
+
</ul>
|
|
196
|
+
)}
|
|
197
|
+
</SectionCard>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Search performance */}
|
|
201
|
+
<SectionCard title="Search Performance" description="Top pages by impressions (last 28 days)">
|
|
202
|
+
{!searchConsoleConnected ? (
|
|
203
|
+
<SeoEmptyState
|
|
204
|
+
icon={<BarChart3 size={24} />}
|
|
205
|
+
title="Search Console not connected"
|
|
206
|
+
description="Connect Google Search Console to see real impressions, clicks, CTR, and average position."
|
|
207
|
+
action={
|
|
208
|
+
onConnectSearchConsole ? (
|
|
209
|
+
<button className={btnSecondary} onClick={onConnectSearchConsole}>
|
|
210
|
+
Connect Search Console
|
|
211
|
+
</button>
|
|
212
|
+
) : undefined
|
|
213
|
+
}
|
|
214
|
+
/>
|
|
215
|
+
) : searchPerformance.length === 0 ? (
|
|
216
|
+
<SeoEmptyState title="No search performance data" />
|
|
217
|
+
) : (
|
|
218
|
+
<div className="overflow-x-auto">
|
|
219
|
+
<table className="w-full text-sm" aria-label="Search performance">
|
|
220
|
+
<thead>
|
|
221
|
+
<tr className="text-muted-foreground border-border border-b text-left">
|
|
222
|
+
<th className="py-2 pr-4 font-medium">Page</th>
|
|
223
|
+
<th className="py-2 pr-4 text-right font-medium">Impressions</th>
|
|
224
|
+
<th className="py-2 pr-4 text-right font-medium">Clicks</th>
|
|
225
|
+
<th className="py-2 pr-4 text-right font-medium">CTR</th>
|
|
226
|
+
<th className="py-2 text-right font-medium">Avg. Position</th>
|
|
227
|
+
</tr>
|
|
228
|
+
</thead>
|
|
229
|
+
<tbody className="divide-border divide-y">
|
|
230
|
+
{searchPerformance.map((row) => (
|
|
231
|
+
<tr key={row.url}>
|
|
232
|
+
<td className="py-2 pr-4">
|
|
233
|
+
<span className="text-foreground block font-medium">{row.title}</span>
|
|
234
|
+
<span className="text-muted-foreground block truncate">{row.url}</span>
|
|
235
|
+
</td>
|
|
236
|
+
<td className="text-foreground py-2 pr-4 text-right tabular-nums">
|
|
237
|
+
{row.impressions.toLocaleString()}
|
|
238
|
+
</td>
|
|
239
|
+
<td className="text-foreground py-2 pr-4 text-right tabular-nums">
|
|
240
|
+
{row.clicks.toLocaleString()}
|
|
241
|
+
</td>
|
|
242
|
+
<td className="text-foreground py-2 pr-4 text-right tabular-nums">
|
|
243
|
+
{(row.ctr * 100).toFixed(1)}%
|
|
244
|
+
</td>
|
|
245
|
+
<td className="text-foreground py-2 text-right tabular-nums">
|
|
246
|
+
{row.averagePosition.toFixed(1)}
|
|
247
|
+
</td>
|
|
248
|
+
</tr>
|
|
249
|
+
))}
|
|
250
|
+
</tbody>
|
|
251
|
+
</table>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</SectionCard>
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|