@actuate-media/cms-admin 0.18.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/__tests__/lib/post-editor-service.test.js +75 -23
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/posts-service.test.js +132 -142
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
- 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/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- 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/post-editor-service.d.ts +6 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +32 -37
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +91 -160
- package/dist/lib/posts-service.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/NewPostPicker.d.ts +5 -4
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
- package/dist/views/Posts/NewPostPicker.js +5 -4
- package/dist/views/Posts/NewPostPicker.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
- package/dist/views/Posts/PostTypesView.d.ts +5 -4
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +8 -0
- package/dist/views/Posts/PostsListView.js.map +1 -1
- 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/page-editor/sections/ArticleBodySection.d.ts +6 -2
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +7 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +10 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +4 -3
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
- package/dist/views/post-editor/PostPreview.js +11 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.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/__tests__/lib/post-editor-service.test.ts +78 -23
- package/src/__tests__/lib/posts-service.test.ts +134 -163
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- 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/index.ts +0 -2
- package/src/layout/Sidebar.tsx +3 -0
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- 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/NewPostPicker.tsx +5 -4
- package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/Posts/PostsListView.tsx +9 -0
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
- package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
- package/src/views/post-editor/PostFieldsModal.tsx +36 -21
- package/src/views/post-editor/PostHeader.tsx +4 -4
- package/src/views/post-editor/PostPreview.tsx +12 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -5
- 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
- package/dist/views/PostEditor.d.ts +0 -6
- package/dist/views/PostEditor.d.ts.map +0 -1
- package/dist/views/PostEditor.js +0 -79
- package/dist/views/PostEditor.js.map +0 -1
- package/dist/views/Posts.d.ts +0 -5
- package/dist/views/Posts.d.ts.map +0 -1
- package/dist/views/Posts.js +0 -73
- package/dist/views/Posts.js.map +0 -1
- package/src/views/PostEditor.tsx +0 -257
- package/src/views/Posts.tsx +0 -375
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Sparkles, ArrowRight, ExternalLink, Image as ImageIcon, Gauge } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import {
|
|
7
|
+
fetchSeoIssue,
|
|
8
|
+
resolveSeoIssue,
|
|
9
|
+
ignoreSeoIssue,
|
|
10
|
+
reopenSeoIssue,
|
|
11
|
+
explainSeoIssue,
|
|
12
|
+
type SeoIssue,
|
|
13
|
+
} from '../../lib/seo-service.js'
|
|
14
|
+
import { Drawer } from './Drawer.js'
|
|
15
|
+
import { SeoLoading, SeoErrorState, severityMeta, btnPrimary, btnSecondary } from './primitives.js'
|
|
16
|
+
|
|
17
|
+
/** Map a fix action to a destination route within the admin. */
|
|
18
|
+
function fixRoute(issue: SeoIssue): { label: string; path: string; icon: React.ReactNode } | null {
|
|
19
|
+
const ent = issue.entityType
|
|
20
|
+
const id = issue.entityId
|
|
21
|
+
switch (issue.fixActionType) {
|
|
22
|
+
case 'edit-metadata':
|
|
23
|
+
case 'edit-canonical':
|
|
24
|
+
case 'edit-og':
|
|
25
|
+
case 'edit-schema':
|
|
26
|
+
if (ent && id) {
|
|
27
|
+
return {
|
|
28
|
+
label: 'Edit content SEO',
|
|
29
|
+
path: `/seo?tab=content&entityType=${ent}&entityId=${encodeURIComponent(id)}`,
|
|
30
|
+
icon: <ArrowRight className="h-4 w-4" aria-hidden />,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
case 'fix-broken-link':
|
|
35
|
+
case 'create-redirect':
|
|
36
|
+
return {
|
|
37
|
+
label: 'Open redirects',
|
|
38
|
+
path: '/seo?tab=redirects',
|
|
39
|
+
icon: <ArrowRight className="h-4 w-4" aria-hidden />,
|
|
40
|
+
}
|
|
41
|
+
case 'optimize-media':
|
|
42
|
+
return {
|
|
43
|
+
label: 'Open Media Center',
|
|
44
|
+
path: '/media',
|
|
45
|
+
icon: <ImageIcon className="h-4 w-4" aria-hidden />,
|
|
46
|
+
}
|
|
47
|
+
case 'improve-speed':
|
|
48
|
+
return {
|
|
49
|
+
label: 'Technical settings',
|
|
50
|
+
path: '/seo?tab=technical',
|
|
51
|
+
icon: <Gauge className="h-4 w-4" aria-hidden />,
|
|
52
|
+
}
|
|
53
|
+
default:
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function SeoIssueFixDrawer({
|
|
59
|
+
open,
|
|
60
|
+
onOpenChange,
|
|
61
|
+
issueId,
|
|
62
|
+
onChanged,
|
|
63
|
+
onNavigate,
|
|
64
|
+
}: {
|
|
65
|
+
open: boolean
|
|
66
|
+
onOpenChange: (open: boolean) => void
|
|
67
|
+
issueId: string | null
|
|
68
|
+
onChanged?: () => void
|
|
69
|
+
onNavigate?: (path: string) => void
|
|
70
|
+
}) {
|
|
71
|
+
const [issue, setIssue] = useState<SeoIssue | null>(null)
|
|
72
|
+
const [loading, setLoading] = useState(false)
|
|
73
|
+
const [error, setError] = useState<string | null>(null)
|
|
74
|
+
const [busy, setBusy] = useState(false)
|
|
75
|
+
const [explanation, setExplanation] = useState<string | null>(null)
|
|
76
|
+
const [explaining, setExplaining] = useState(false)
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open || !issueId) return
|
|
80
|
+
let alive = true
|
|
81
|
+
setLoading(true)
|
|
82
|
+
setError(null)
|
|
83
|
+
setExplanation(null)
|
|
84
|
+
fetchSeoIssue(issueId)
|
|
85
|
+
.then((i) => {
|
|
86
|
+
if (!alive) return
|
|
87
|
+
if (!i) setError('Issue not found.')
|
|
88
|
+
else setIssue(i)
|
|
89
|
+
})
|
|
90
|
+
.catch((e) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
|
|
91
|
+
.finally(() => alive && setLoading(false))
|
|
92
|
+
return () => {
|
|
93
|
+
alive = false
|
|
94
|
+
}
|
|
95
|
+
}, [open, issueId])
|
|
96
|
+
|
|
97
|
+
async function mutate(fn: (id: string) => Promise<{ error?: string }>, successMsg: string) {
|
|
98
|
+
if (!issue) return
|
|
99
|
+
setBusy(true)
|
|
100
|
+
const res = await fn(issue.id)
|
|
101
|
+
setBusy(false)
|
|
102
|
+
if (res.error) return toast.error(res.error)
|
|
103
|
+
toast.success(successMsg)
|
|
104
|
+
onOpenChange(false)
|
|
105
|
+
onChanged?.()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handleExplain() {
|
|
109
|
+
if (!issue) return
|
|
110
|
+
setExplaining(true)
|
|
111
|
+
const res = await explainSeoIssue(issue.id)
|
|
112
|
+
setExplaining(false)
|
|
113
|
+
if (res.unavailable) {
|
|
114
|
+
toast.error('AI is not configured.')
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
setExplanation(res.text ?? 'No explanation available.')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const route = issue ? fixRoute(issue) : null
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Drawer
|
|
124
|
+
open={open}
|
|
125
|
+
onOpenChange={onOpenChange}
|
|
126
|
+
title="Issue detail"
|
|
127
|
+
width="max-w-lg"
|
|
128
|
+
footer={
|
|
129
|
+
issue ? (
|
|
130
|
+
<>
|
|
131
|
+
{issue.status === 'ignored' ? (
|
|
132
|
+
<button
|
|
133
|
+
className={btnSecondary}
|
|
134
|
+
onClick={() => mutate(reopenSeoIssue, 'Issue reopened.')}
|
|
135
|
+
disabled={busy}
|
|
136
|
+
>
|
|
137
|
+
Reopen
|
|
138
|
+
</button>
|
|
139
|
+
) : (
|
|
140
|
+
<button
|
|
141
|
+
className={btnSecondary}
|
|
142
|
+
onClick={() => mutate(ignoreSeoIssue, 'Issue ignored.')}
|
|
143
|
+
disabled={busy}
|
|
144
|
+
>
|
|
145
|
+
Ignore
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
{route && onNavigate ? (
|
|
149
|
+
<button
|
|
150
|
+
className={btnPrimary}
|
|
151
|
+
onClick={() => {
|
|
152
|
+
onNavigate(route.path)
|
|
153
|
+
onOpenChange(false)
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{route.icon}
|
|
157
|
+
{route.label}
|
|
158
|
+
</button>
|
|
159
|
+
) : (
|
|
160
|
+
<button
|
|
161
|
+
className={btnPrimary}
|
|
162
|
+
onClick={() => mutate(resolveSeoIssue, 'Issue resolved.')}
|
|
163
|
+
disabled={busy}
|
|
164
|
+
>
|
|
165
|
+
Mark resolved
|
|
166
|
+
</button>
|
|
167
|
+
)}
|
|
168
|
+
</>
|
|
169
|
+
) : null
|
|
170
|
+
}
|
|
171
|
+
>
|
|
172
|
+
{loading ? (
|
|
173
|
+
<SeoLoading />
|
|
174
|
+
) : error ? (
|
|
175
|
+
<SeoErrorState message={error} />
|
|
176
|
+
) : issue ? (
|
|
177
|
+
<div className="space-y-4">
|
|
178
|
+
{(() => {
|
|
179
|
+
const meta = severityMeta(issue.severity)
|
|
180
|
+
return (
|
|
181
|
+
<span
|
|
182
|
+
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.bg} ${meta.text}`}
|
|
183
|
+
>
|
|
184
|
+
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
|
|
185
|
+
{meta.label} · {issue.category}
|
|
186
|
+
</span>
|
|
187
|
+
)
|
|
188
|
+
})()}
|
|
189
|
+
|
|
190
|
+
<div>
|
|
191
|
+
<h3 className="text-foreground text-base font-medium">{issue.title}</h3>
|
|
192
|
+
{issue.entityTitle && (
|
|
193
|
+
<p className="text-muted-foreground mt-0.5 text-sm">{issue.entityTitle}</p>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{issue.url && (
|
|
198
|
+
<a
|
|
199
|
+
href={issue.url}
|
|
200
|
+
target="_blank"
|
|
201
|
+
rel="noopener noreferrer"
|
|
202
|
+
className="text-primary inline-flex items-center gap-1 text-sm hover:underline"
|
|
203
|
+
>
|
|
204
|
+
{issue.url}
|
|
205
|
+
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
206
|
+
</a>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{issue.description && <p className="text-foreground text-sm">{issue.description}</p>}
|
|
210
|
+
|
|
211
|
+
{issue.impact && (
|
|
212
|
+
<div className="border-border rounded-md border p-3">
|
|
213
|
+
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
214
|
+
Impact
|
|
215
|
+
</p>
|
|
216
|
+
<p className="text-foreground mt-1 text-sm">{issue.impact}</p>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{issue.recommendation && (
|
|
221
|
+
<div className="border-border rounded-md border p-3">
|
|
222
|
+
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
223
|
+
Recommendation
|
|
224
|
+
</p>
|
|
225
|
+
<p className="text-foreground mt-1 text-sm">{issue.recommendation}</p>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
<div className="border-border rounded-md border p-3">
|
|
230
|
+
<button
|
|
231
|
+
onClick={handleExplain}
|
|
232
|
+
disabled={explaining}
|
|
233
|
+
className="text-primary focus-visible:ring-ring inline-flex items-center gap-1.5 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
234
|
+
>
|
|
235
|
+
<Sparkles className={`h-4 w-4 ${explaining ? 'animate-pulse' : ''}`} aria-hidden />
|
|
236
|
+
{explaining ? 'Asking AI…' : 'Explain with AI'}
|
|
237
|
+
</button>
|
|
238
|
+
{explanation && <p className="text-foreground mt-2 text-sm">{explanation}</p>}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
) : null}
|
|
242
|
+
</Drawer>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared SEO UI primitives — token-only, accessible building blocks used across
|
|
5
|
+
* the five SEO tabs. No raw palette colours: status uses the theme's
|
|
6
|
+
* success / warning / destructive / muted tokens.
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactNode } from 'react'
|
|
9
|
+
import { AlertTriangle, Loader2 } from 'lucide-react'
|
|
10
|
+
import type { SeoGrade, SeoSeverity } from '../../lib/seo-service.js'
|
|
11
|
+
|
|
12
|
+
// ─── Grade + severity styling ────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface GradeMeta {
|
|
15
|
+
label: string
|
|
16
|
+
text: string
|
|
17
|
+
bg: string
|
|
18
|
+
dot: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function gradeMeta(grade: SeoGrade): GradeMeta {
|
|
22
|
+
switch (grade) {
|
|
23
|
+
case 'good':
|
|
24
|
+
return { label: 'Good', text: 'text-success', bg: 'bg-success/10', dot: 'bg-success' }
|
|
25
|
+
case 'fair':
|
|
26
|
+
return { label: 'Fair', text: 'text-warning', bg: 'bg-warning/10', dot: 'bg-warning' }
|
|
27
|
+
case 'poor':
|
|
28
|
+
return {
|
|
29
|
+
label: 'Poor',
|
|
30
|
+
text: 'text-destructive',
|
|
31
|
+
bg: 'bg-destructive/10',
|
|
32
|
+
dot: 'bg-destructive',
|
|
33
|
+
}
|
|
34
|
+
default:
|
|
35
|
+
return {
|
|
36
|
+
label: 'Critical',
|
|
37
|
+
text: 'text-destructive',
|
|
38
|
+
bg: 'bg-destructive/15',
|
|
39
|
+
dot: 'bg-destructive',
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Grade band from a numeric 0-100 score. */
|
|
45
|
+
export function gradeFromScore(score: number): SeoGrade {
|
|
46
|
+
if (score >= 80) return 'good'
|
|
47
|
+
if (score >= 60) return 'fair'
|
|
48
|
+
if (score >= 1) return 'poor'
|
|
49
|
+
return 'critical'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function severityMeta(severity: SeoSeverity): GradeMeta {
|
|
53
|
+
switch (severity) {
|
|
54
|
+
case 'critical':
|
|
55
|
+
return {
|
|
56
|
+
label: 'Critical',
|
|
57
|
+
text: 'text-destructive',
|
|
58
|
+
bg: 'bg-destructive/10',
|
|
59
|
+
dot: 'bg-destructive',
|
|
60
|
+
}
|
|
61
|
+
case 'warning':
|
|
62
|
+
return { label: 'Warning', text: 'text-warning', bg: 'bg-warning/10', dot: 'bg-warning' }
|
|
63
|
+
default:
|
|
64
|
+
return {
|
|
65
|
+
label: 'Info',
|
|
66
|
+
text: 'text-muted-foreground',
|
|
67
|
+
bg: 'bg-muted',
|
|
68
|
+
dot: 'bg-muted-foreground',
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Badges + bars ───────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function SeoStatusBadge({
|
|
76
|
+
label,
|
|
77
|
+
tone,
|
|
78
|
+
}: {
|
|
79
|
+
label: string
|
|
80
|
+
tone: 'good' | 'fair' | 'poor' | 'critical' | 'neutral'
|
|
81
|
+
}) {
|
|
82
|
+
const cls =
|
|
83
|
+
tone === 'good'
|
|
84
|
+
? 'bg-success/10 text-success'
|
|
85
|
+
: tone === 'fair'
|
|
86
|
+
? 'bg-warning/10 text-warning'
|
|
87
|
+
: tone === 'poor' || tone === 'critical'
|
|
88
|
+
? 'bg-destructive/10 text-destructive'
|
|
89
|
+
: 'bg-muted text-muted-foreground'
|
|
90
|
+
return (
|
|
91
|
+
<span
|
|
92
|
+
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${cls}`}
|
|
93
|
+
>
|
|
94
|
+
<span
|
|
95
|
+
className={`h-1.5 w-1.5 rounded-full ${
|
|
96
|
+
tone === 'good'
|
|
97
|
+
? 'bg-success'
|
|
98
|
+
: tone === 'fair'
|
|
99
|
+
? 'bg-warning'
|
|
100
|
+
: tone === 'neutral'
|
|
101
|
+
? 'bg-muted-foreground'
|
|
102
|
+
: 'bg-destructive'
|
|
103
|
+
}`}
|
|
104
|
+
aria-hidden
|
|
105
|
+
/>
|
|
106
|
+
{label}
|
|
107
|
+
</span>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A horizontal score bar (0-100), coloured by grade. */
|
|
112
|
+
export function SeoScoreBar({ score, className = '' }: { score: number; className?: string }) {
|
|
113
|
+
const grade = gradeFromScore(score)
|
|
114
|
+
const fill = grade === 'good' ? 'bg-success' : grade === 'fair' ? 'bg-warning' : 'bg-destructive'
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className={`bg-muted h-2 w-full overflow-hidden rounded-full ${className}`}
|
|
118
|
+
role="progressbar"
|
|
119
|
+
aria-valuenow={score}
|
|
120
|
+
aria-valuemin={0}
|
|
121
|
+
aria-valuemax={100}
|
|
122
|
+
>
|
|
123
|
+
<div
|
|
124
|
+
className={`h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none ${fill}`}
|
|
125
|
+
style={{ width: `${Math.max(0, Math.min(100, score))}%` }}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function ScorePill({ score }: { score: number }) {
|
|
132
|
+
const meta = gradeMeta(gradeFromScore(score))
|
|
133
|
+
return (
|
|
134
|
+
<span
|
|
135
|
+
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.bg} ${meta.text}`}
|
|
136
|
+
>
|
|
137
|
+
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
|
|
138
|
+
{score}
|
|
139
|
+
</span>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Layout cards + states ───────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export function SectionCard({
|
|
146
|
+
title,
|
|
147
|
+
description,
|
|
148
|
+
action,
|
|
149
|
+
children,
|
|
150
|
+
className = '',
|
|
151
|
+
}: {
|
|
152
|
+
title?: string
|
|
153
|
+
description?: string
|
|
154
|
+
action?: ReactNode
|
|
155
|
+
children: ReactNode
|
|
156
|
+
className?: string
|
|
157
|
+
}) {
|
|
158
|
+
return (
|
|
159
|
+
<section className={`border-border bg-card rounded-lg border ${className}`}>
|
|
160
|
+
{(title || action) && (
|
|
161
|
+
<header className="border-border flex items-center justify-between gap-3 border-b px-4 py-3">
|
|
162
|
+
<div className="min-w-0">
|
|
163
|
+
{title && <h3 className="text-foreground text-base font-medium">{title}</h3>}
|
|
164
|
+
{description && <p className="text-muted-foreground mt-0.5 text-sm">{description}</p>}
|
|
165
|
+
</div>
|
|
166
|
+
{action}
|
|
167
|
+
</header>
|
|
168
|
+
)}
|
|
169
|
+
<div className="p-4">{children}</div>
|
|
170
|
+
</section>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function SeoLoading({ label = 'Loading…' }: { label?: string }) {
|
|
175
|
+
return (
|
|
176
|
+
<div className="flex h-64 items-center justify-center" role="status" aria-live="polite">
|
|
177
|
+
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
|
|
178
|
+
<span className="sr-only">{label}</span>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function SeoEmptyState({
|
|
184
|
+
icon,
|
|
185
|
+
title,
|
|
186
|
+
description,
|
|
187
|
+
action,
|
|
188
|
+
}: {
|
|
189
|
+
icon?: ReactNode
|
|
190
|
+
title: string
|
|
191
|
+
description?: string
|
|
192
|
+
action?: ReactNode
|
|
193
|
+
}) {
|
|
194
|
+
return (
|
|
195
|
+
<div className="border-border flex flex-col items-center justify-center rounded-lg border border-dashed px-6 py-12 text-center">
|
|
196
|
+
{icon && (
|
|
197
|
+
<div className="text-muted-foreground mb-3" aria-hidden>
|
|
198
|
+
{icon}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
<h3 className="text-foreground text-base font-medium">{title}</h3>
|
|
202
|
+
{description && <p className="text-muted-foreground mt-1 max-w-md text-sm">{description}</p>}
|
|
203
|
+
{action && <div className="mt-4">{action}</div>}
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function SeoErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-3"
|
|
212
|
+
role="alert"
|
|
213
|
+
>
|
|
214
|
+
<AlertTriangle className="text-destructive h-5 w-5 shrink-0" aria-hidden />
|
|
215
|
+
<span className="text-destructive flex-1 text-sm">{message}</span>
|
|
216
|
+
{onRetry && (
|
|
217
|
+
<button
|
|
218
|
+
onClick={onRetry}
|
|
219
|
+
className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-1 text-sm transition-colors"
|
|
220
|
+
>
|
|
221
|
+
Retry
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Button helpers (token-only) ─────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export const btnPrimary =
|
|
231
|
+
'inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-base font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60'
|
|
232
|
+
|
|
233
|
+
export const btnSecondary =
|
|
234
|
+
'inline-flex items-center justify-center gap-2 rounded-md border border-border bg-card px-4 py-2 text-base font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60'
|
|
235
|
+
|
|
236
|
+
export const btnGhostIcon =
|
|
237
|
+
'inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
package/src/index.ts
CHANGED
|
@@ -37,9 +37,7 @@ export type {
|
|
|
37
37
|
} from './layout/primitives/index.js'
|
|
38
38
|
|
|
39
39
|
export { Dashboard } from './views/Dashboard.js'
|
|
40
|
-
export { Posts } from './views/Posts.js'
|
|
41
40
|
export { Pages } from './views/Pages.js'
|
|
42
|
-
export { PostEditor } from './views/PostEditor.js'
|
|
43
41
|
export { PageEditor } from './views/PageEditor.js'
|
|
44
42
|
export { MediaBrowser } from './views/MediaBrowser.js'
|
|
45
43
|
export { Forms } from './views/Forms.js'
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -233,6 +233,9 @@ interface NavRenderContext {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
function isPathActive(currentPath: string, itemPath: string): boolean {
|
|
236
|
+
// Ignore query string / hash so state-bearing routes (e.g. `/seo?tab=audit`)
|
|
237
|
+
// still highlight their nav item.
|
|
238
|
+
currentPath = currentPath.split('?')[0]!.split('#')[0]!
|
|
236
239
|
if (itemPath === '/') return currentPath === '/'
|
|
237
240
|
|
|
238
241
|
// Special case: /posts is a prefix of /posts/types — without this
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
updateSectionMeta,
|
|
35
35
|
coerceSections,
|
|
36
36
|
coercePostHeader,
|
|
37
|
-
|
|
37
|
+
createPostTemplate,
|
|
38
38
|
validateSectionContent,
|
|
39
39
|
type PageSection,
|
|
40
40
|
type PostHeaderConfig,
|
|
@@ -185,59 +185,60 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
|
|
|
185
185
|
|
|
186
186
|
// ─── Templates ───────────────────────────────────────────────────────
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Shape returned by the dedicated `/post-templates/:type` endpoint
|
|
190
|
+
* (`{ data: { postType, docId, header, sections } }`, unwrapped by cmsApi).
|
|
191
|
+
*/
|
|
192
|
+
interface TemplateApiResponse {
|
|
193
|
+
postType: string
|
|
194
|
+
docId: string | null
|
|
195
|
+
header: unknown
|
|
196
|
+
sections: unknown
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function templateEndpoint(postType: string): string {
|
|
200
|
+
return `/post-templates/${encodeURIComponent(postType)}`
|
|
191
201
|
}
|
|
192
202
|
|
|
193
203
|
/**
|
|
194
|
-
* Load a post type's template
|
|
195
|
-
*
|
|
196
|
-
*
|
|
204
|
+
* Load a post type's template via the dedicated, audited `/post-templates/:type`
|
|
205
|
+
* endpoint (scoped + logged), rather than the raw collection API. When the type
|
|
206
|
+
* has no saved template yet the endpoint returns the default starting template;
|
|
207
|
+
* if the request fails outright we still hand back that default so the editor
|
|
208
|
+
* is never blank.
|
|
197
209
|
*/
|
|
198
210
|
export async function fetchTemplateForEditor(postType: string): Promise<EditorPostTemplate> {
|
|
199
|
-
const res = await cmsApi<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return { docId: null, postType, header: defaultPostHeader(), sections: [] }
|
|
211
|
+
const res = await cmsApi<TemplateApiResponse>(templateEndpoint(postType))
|
|
212
|
+
if (res.error || !res.data) {
|
|
213
|
+
const seed = createPostTemplate(postType)
|
|
214
|
+
return { docId: null, postType, header: seed.header, sections: seed.sections }
|
|
204
215
|
}
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
const r = res.data
|
|
217
|
+
const docId = r.docId ?? null
|
|
218
|
+
const header = coercePostHeader(r.header)
|
|
219
|
+
let sections = coerceSections(r.sections)
|
|
220
|
+
// Defend against an older server (or an empty stored template): an unsaved
|
|
221
|
+
// type should still seed the default sections so authors aren't faced with a
|
|
222
|
+
// blank canvas.
|
|
223
|
+
if (!docId && sections.length === 0) {
|
|
224
|
+
sections = createPostTemplate(postType).sections
|
|
211
225
|
}
|
|
226
|
+
return { docId, postType, header, sections }
|
|
212
227
|
}
|
|
213
228
|
|
|
214
|
-
|
|
215
|
-
return JSON.stringify({
|
|
216
|
-
title: `${template.postType} template`,
|
|
217
|
-
slug: template.postType,
|
|
218
|
-
postType: template.postType,
|
|
219
|
-
header: template.header,
|
|
220
|
-
sections: template.sections,
|
|
221
|
-
...extra,
|
|
222
|
-
})
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Persist a post type's template (creates the doc on first save). */
|
|
229
|
+
/** Persist a post type's template via `PUT /post-templates/:type`. */
|
|
226
230
|
export async function saveTemplate(template: EditorPostTemplate): Promise<EditorPostTemplate> {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
:
|
|
230
|
-
const res = await cmsApi<DocumentApiRow>(endpoint, {
|
|
231
|
-
method: template.docId ? 'PUT' : 'POST',
|
|
232
|
-
body: templateWriteBody(template, { status: 'PUBLISHED' }),
|
|
231
|
+
const res = await cmsApi<TemplateApiResponse>(templateEndpoint(template.postType), {
|
|
232
|
+
method: 'PUT',
|
|
233
|
+
body: JSON.stringify({ header: template.header, sections: template.sections }),
|
|
233
234
|
})
|
|
234
235
|
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save template')
|
|
235
|
-
const
|
|
236
|
+
const r = res.data
|
|
236
237
|
return {
|
|
237
|
-
docId:
|
|
238
|
+
docId: r.docId ?? null,
|
|
238
239
|
postType: template.postType,
|
|
239
|
-
header: coercePostHeader(
|
|
240
|
-
sections: coerceSections(
|
|
240
|
+
header: coercePostHeader(r.header),
|
|
241
|
+
sections: coerceSections(r.sections),
|
|
241
242
|
}
|
|
242
243
|
}
|
|
243
244
|
|