@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,235 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import {
|
|
6
|
+
createRedirectRule,
|
|
7
|
+
updateRedirectRule,
|
|
8
|
+
type RedirectRule,
|
|
9
|
+
type RedirectRulePayload,
|
|
10
|
+
type RedirectStatusCode,
|
|
11
|
+
} from '../../lib/seo-service.js'
|
|
12
|
+
import { Drawer } from './Drawer.js'
|
|
13
|
+
import { btnPrimary, btnSecondary } from './primitives.js'
|
|
14
|
+
|
|
15
|
+
const STATUS_CODES: { value: RedirectStatusCode; label: string }[] = [
|
|
16
|
+
{ value: 301, label: '301 — Permanent' },
|
|
17
|
+
{ value: 302, label: '302 — Temporary' },
|
|
18
|
+
{ value: 307, label: '307 — Temporary (preserve method)' },
|
|
19
|
+
{ value: 308, label: '308 — Permanent (preserve method)' },
|
|
20
|
+
{ value: 410, label: '410 — Gone (no destination)' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const inputCls =
|
|
24
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
25
|
+
|
|
26
|
+
function normalizePath(p: string): string {
|
|
27
|
+
const t = p.trim()
|
|
28
|
+
if (!t) return ''
|
|
29
|
+
if (/^https?:\/\//i.test(t)) return t
|
|
30
|
+
return t.startsWith('/') ? t : `/${t}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function RedirectEditorDrawer({
|
|
34
|
+
open,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
rule,
|
|
37
|
+
onSaved,
|
|
38
|
+
}: {
|
|
39
|
+
open: boolean
|
|
40
|
+
onOpenChange: (open: boolean) => void
|
|
41
|
+
/** null = create mode */
|
|
42
|
+
rule: RedirectRule | null
|
|
43
|
+
onSaved?: () => void
|
|
44
|
+
}) {
|
|
45
|
+
const [form, setForm] = useState<RedirectRulePayload>({
|
|
46
|
+
type: 301,
|
|
47
|
+
fromPath: '',
|
|
48
|
+
toPath: '',
|
|
49
|
+
enabled: true,
|
|
50
|
+
preserveQuery: false,
|
|
51
|
+
notes: '',
|
|
52
|
+
})
|
|
53
|
+
const [saving, setSaving] = useState(false)
|
|
54
|
+
const [clientError, setClientError] = useState<string | null>(null)
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open) return
|
|
58
|
+
if (rule) {
|
|
59
|
+
setForm({
|
|
60
|
+
type: rule.type,
|
|
61
|
+
fromPath: rule.fromPath,
|
|
62
|
+
toPath: rule.toPath,
|
|
63
|
+
enabled: rule.enabled,
|
|
64
|
+
preserveQuery: rule.preserveQuery,
|
|
65
|
+
notes: rule.notes ?? '',
|
|
66
|
+
})
|
|
67
|
+
} else {
|
|
68
|
+
setForm({
|
|
69
|
+
type: 301,
|
|
70
|
+
fromPath: '',
|
|
71
|
+
toPath: '',
|
|
72
|
+
enabled: true,
|
|
73
|
+
preserveQuery: false,
|
|
74
|
+
notes: '',
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
setClientError(null)
|
|
78
|
+
}, [open, rule])
|
|
79
|
+
|
|
80
|
+
const isGone = form.type === 410
|
|
81
|
+
const update = (patch: Partial<RedirectRulePayload>) => setForm((f) => ({ ...f, ...patch }))
|
|
82
|
+
|
|
83
|
+
async function handleSave() {
|
|
84
|
+
const fromPath = normalizePath(form.fromPath)
|
|
85
|
+
const toPath = isGone ? '' : normalizePath(form.toPath)
|
|
86
|
+
if (!fromPath) {
|
|
87
|
+
setClientError('Source path is required.')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (!isGone && !toPath) {
|
|
91
|
+
setClientError('Destination is required for redirect status codes.')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (!isGone && fromPath === toPath) {
|
|
95
|
+
setClientError('Source and destination cannot be identical (would create a loop).')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
setClientError(null)
|
|
99
|
+
setSaving(true)
|
|
100
|
+
const payload: RedirectRulePayload = { ...form, fromPath, toPath }
|
|
101
|
+
const res = rule
|
|
102
|
+
? await updateRedirectRule(rule.id, payload)
|
|
103
|
+
: await createRedirectRule(payload)
|
|
104
|
+
setSaving(false)
|
|
105
|
+
if (res.error) {
|
|
106
|
+
toast.error(res.error)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
toast.success(rule ? 'Redirect updated.' : 'Redirect created.')
|
|
110
|
+
onOpenChange(false)
|
|
111
|
+
onSaved?.()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Drawer
|
|
116
|
+
open={open}
|
|
117
|
+
onOpenChange={onOpenChange}
|
|
118
|
+
title={rule ? 'Edit redirect' : 'New redirect'}
|
|
119
|
+
width="max-w-lg"
|
|
120
|
+
footer={
|
|
121
|
+
<>
|
|
122
|
+
<button className={btnSecondary} onClick={() => onOpenChange(false)} disabled={saving}>
|
|
123
|
+
Cancel
|
|
124
|
+
</button>
|
|
125
|
+
<button className={btnPrimary} onClick={handleSave} disabled={saving}>
|
|
126
|
+
{saving ? 'Saving…' : rule ? 'Save changes' : 'Create redirect'}
|
|
127
|
+
</button>
|
|
128
|
+
</>
|
|
129
|
+
}
|
|
130
|
+
>
|
|
131
|
+
<div className="space-y-4">
|
|
132
|
+
{clientError && (
|
|
133
|
+
<p
|
|
134
|
+
className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm"
|
|
135
|
+
role="alert"
|
|
136
|
+
>
|
|
137
|
+
{clientError}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
<div className="space-y-1.5">
|
|
141
|
+
<label className="text-foreground text-sm font-medium" htmlFor="redir-type">
|
|
142
|
+
Status code
|
|
143
|
+
</label>
|
|
144
|
+
<select
|
|
145
|
+
id="redir-type"
|
|
146
|
+
className={inputCls}
|
|
147
|
+
value={form.type}
|
|
148
|
+
onChange={(e) => update({ type: Number(e.target.value) as RedirectStatusCode })}
|
|
149
|
+
>
|
|
150
|
+
{STATUS_CODES.map((s) => (
|
|
151
|
+
<option key={s.value} value={s.value}>
|
|
152
|
+
{s.label}
|
|
153
|
+
</option>
|
|
154
|
+
))}
|
|
155
|
+
</select>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="space-y-1.5">
|
|
159
|
+
<label className="text-foreground text-sm font-medium" htmlFor="redir-from">
|
|
160
|
+
Source path
|
|
161
|
+
</label>
|
|
162
|
+
<input
|
|
163
|
+
id="redir-from"
|
|
164
|
+
className={inputCls}
|
|
165
|
+
value={form.fromPath}
|
|
166
|
+
onChange={(e) => update({ fromPath: e.target.value })}
|
|
167
|
+
placeholder="/old-path"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{!isGone && (
|
|
172
|
+
<div className="space-y-1.5">
|
|
173
|
+
<label className="text-foreground text-sm font-medium" htmlFor="redir-to">
|
|
174
|
+
Destination
|
|
175
|
+
</label>
|
|
176
|
+
<input
|
|
177
|
+
id="redir-to"
|
|
178
|
+
className={inputCls}
|
|
179
|
+
value={form.toPath}
|
|
180
|
+
onChange={(e) => update({ toPath: e.target.value })}
|
|
181
|
+
placeholder="/new-path or https://example.com/page"
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<div className="space-y-1.5">
|
|
187
|
+
<label className="text-foreground text-sm font-medium" htmlFor="redir-notes">
|
|
188
|
+
Notes <span className="text-muted-foreground font-normal">(optional)</span>
|
|
189
|
+
</label>
|
|
190
|
+
<input
|
|
191
|
+
id="redir-notes"
|
|
192
|
+
className={inputCls}
|
|
193
|
+
value={form.notes ?? ''}
|
|
194
|
+
onChange={(e) => update({ notes: e.target.value })}
|
|
195
|
+
placeholder="Why this redirect exists"
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div className="border-border space-y-3 rounded-lg border p-4">
|
|
200
|
+
<label className="flex items-center justify-between gap-3">
|
|
201
|
+
<span className="text-foreground text-sm font-medium">Enabled</span>
|
|
202
|
+
<input
|
|
203
|
+
type="checkbox"
|
|
204
|
+
role="switch"
|
|
205
|
+
aria-checked={!!form.enabled}
|
|
206
|
+
checked={!!form.enabled}
|
|
207
|
+
onChange={(e) => update({ enabled: e.target.checked })}
|
|
208
|
+
className="accent-primary h-4 w-4"
|
|
209
|
+
/>
|
|
210
|
+
</label>
|
|
211
|
+
{!isGone && (
|
|
212
|
+
<label className="flex items-center justify-between gap-3">
|
|
213
|
+
<span className="min-w-0">
|
|
214
|
+
<span className="text-foreground block text-sm font-medium">
|
|
215
|
+
Preserve query string
|
|
216
|
+
</span>
|
|
217
|
+
<span className="text-muted-foreground block text-sm">
|
|
218
|
+
Append the original query params to the destination.
|
|
219
|
+
</span>
|
|
220
|
+
</span>
|
|
221
|
+
<input
|
|
222
|
+
type="checkbox"
|
|
223
|
+
role="switch"
|
|
224
|
+
aria-checked={!!form.preserveQuery}
|
|
225
|
+
checked={!!form.preserveQuery}
|
|
226
|
+
onChange={(e) => update({ preserveQuery: e.target.checked })}
|
|
227
|
+
className="accent-primary h-4 w-4 shrink-0"
|
|
228
|
+
/>
|
|
229
|
+
</label>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</Drawer>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Sparkles, Bot } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { summarizeSeoAudit } from '../../lib/seo-service.js'
|
|
7
|
+
import { Drawer } from './Drawer.js'
|
|
8
|
+
import { SeoLoading, SeoEmptyState, btnPrimary } from './primitives.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AI Copilot drawer — summarizes the current SEO state and surfaces the top
|
|
12
|
+
* priorities. Degrades gracefully when AI is unconfigured. All AI text is
|
|
13
|
+
* advisory; the editor takes action via the tabs/drawers.
|
|
14
|
+
*/
|
|
15
|
+
export function SeoCopilotDrawer({
|
|
16
|
+
open,
|
|
17
|
+
onOpenChange,
|
|
18
|
+
}: {
|
|
19
|
+
open: boolean
|
|
20
|
+
onOpenChange: (open: boolean) => void
|
|
21
|
+
}) {
|
|
22
|
+
const [loading, setLoading] = useState(false)
|
|
23
|
+
const [summary, setSummary] = useState<string | null>(null)
|
|
24
|
+
const [unavailable, setUnavailable] = useState(false)
|
|
25
|
+
|
|
26
|
+
async function handleSummarize() {
|
|
27
|
+
setLoading(true)
|
|
28
|
+
setUnavailable(false)
|
|
29
|
+
const res = await summarizeSeoAudit()
|
|
30
|
+
setLoading(false)
|
|
31
|
+
if (res.unavailable) {
|
|
32
|
+
setUnavailable(true)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (res.text) setSummary(res.text)
|
|
36
|
+
else toast.error('No summary was returned.')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Drawer
|
|
41
|
+
open={open}
|
|
42
|
+
onOpenChange={onOpenChange}
|
|
43
|
+
title="SEO Copilot"
|
|
44
|
+
description="AI-assisted analysis of your site's SEO health"
|
|
45
|
+
width="max-w-md"
|
|
46
|
+
footer={
|
|
47
|
+
<button className={btnPrimary} onClick={handleSummarize} disabled={loading}>
|
|
48
|
+
<Sparkles className={`h-4 w-4 ${loading ? 'animate-pulse' : ''}`} aria-hidden />
|
|
49
|
+
{loading ? 'Analyzing…' : summary ? 'Re-analyze' : 'Analyze my SEO'}
|
|
50
|
+
</button>
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
{loading ? (
|
|
54
|
+
<SeoLoading label="Analyzing your SEO…" />
|
|
55
|
+
) : unavailable ? (
|
|
56
|
+
<SeoEmptyState
|
|
57
|
+
icon={<Bot size={24} />}
|
|
58
|
+
title="AI is not configured"
|
|
59
|
+
description="Install and configure an AI provider (plugin-ai) to enable the SEO Copilot."
|
|
60
|
+
/>
|
|
61
|
+
) : summary ? (
|
|
62
|
+
<div className="text-foreground space-y-3 text-sm leading-relaxed">
|
|
63
|
+
{summary.split(/\n+/).map((para, i) => (
|
|
64
|
+
<p key={i}>{para}</p>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
) : (
|
|
68
|
+
<SeoEmptyState
|
|
69
|
+
icon={<Bot size={24} />}
|
|
70
|
+
title="Ask the Copilot"
|
|
71
|
+
description="Get an AI summary of your open issues and the top priorities to fix first."
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
</Drawer>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Sparkles, Check } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import {
|
|
7
|
+
fetchSeoContentRecord,
|
|
8
|
+
updateSeoFields,
|
|
9
|
+
generateSeoField,
|
|
10
|
+
type SeoContentRecord,
|
|
11
|
+
type SeoFieldPayload,
|
|
12
|
+
} from '../../lib/seo-service.js'
|
|
13
|
+
import { Drawer } from './Drawer.js'
|
|
14
|
+
import { SeoLoading, SeoErrorState, btnPrimary, btnSecondary, gradeMeta } from './primitives.js'
|
|
15
|
+
|
|
16
|
+
const META_TITLE_MAX = 60
|
|
17
|
+
const META_DESC_MAX = 160
|
|
18
|
+
|
|
19
|
+
type AiField = 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword'
|
|
20
|
+
|
|
21
|
+
function CharCount({ value, max }: { value: string; max: number }) {
|
|
22
|
+
const over = value.length > max
|
|
23
|
+
return (
|
|
24
|
+
<span className={`text-xs ${over ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
25
|
+
{value.length}/{max}
|
|
26
|
+
</span>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Field({
|
|
31
|
+
label,
|
|
32
|
+
children,
|
|
33
|
+
hint,
|
|
34
|
+
}: {
|
|
35
|
+
label: string
|
|
36
|
+
children: React.ReactNode
|
|
37
|
+
hint?: React.ReactNode
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-1.5">
|
|
41
|
+
<div className="flex items-center justify-between">
|
|
42
|
+
<label className="text-foreground text-sm font-medium">{label}</label>
|
|
43
|
+
{hint}
|
|
44
|
+
</div>
|
|
45
|
+
{children}
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const inputCls =
|
|
51
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
52
|
+
|
|
53
|
+
export function SeoEditorDrawer({
|
|
54
|
+
open,
|
|
55
|
+
onOpenChange,
|
|
56
|
+
entityType,
|
|
57
|
+
entityId,
|
|
58
|
+
onSaved,
|
|
59
|
+
}: {
|
|
60
|
+
open: boolean
|
|
61
|
+
onOpenChange: (open: boolean) => void
|
|
62
|
+
entityType: string | null
|
|
63
|
+
entityId: string | null
|
|
64
|
+
onSaved?: () => void
|
|
65
|
+
}) {
|
|
66
|
+
const [record, setRecord] = useState<SeoContentRecord | null>(null)
|
|
67
|
+
const [loading, setLoading] = useState(false)
|
|
68
|
+
const [error, setError] = useState<string | null>(null)
|
|
69
|
+
const [saving, setSaving] = useState(false)
|
|
70
|
+
const [aiBusy, setAiBusy] = useState<AiField | null>(null)
|
|
71
|
+
const [form, setForm] = useState<SeoFieldPayload>({})
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open || !entityType || !entityId) return
|
|
75
|
+
let alive = true
|
|
76
|
+
setLoading(true)
|
|
77
|
+
setError(null)
|
|
78
|
+
fetchSeoContentRecord(entityType, entityId)
|
|
79
|
+
.then((rec) => {
|
|
80
|
+
if (!alive) return
|
|
81
|
+
if (!rec) {
|
|
82
|
+
setError('Could not load this record.')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
setRecord(rec)
|
|
86
|
+
setForm({
|
|
87
|
+
metaTitle: rec.metaTitle ?? '',
|
|
88
|
+
metaDescription: rec.metaDescription ?? '',
|
|
89
|
+
canonicalUrl: rec.canonicalUrl ?? '',
|
|
90
|
+
focusKeyword: rec.focusKeyword ?? '',
|
|
91
|
+
ogTitle: rec.ogTitle ?? '',
|
|
92
|
+
ogDescription: rec.ogDescription ?? '',
|
|
93
|
+
noindex: rec.noindex,
|
|
94
|
+
nofollow: rec.nofollow,
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
.catch((e) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
|
|
98
|
+
.finally(() => alive && setLoading(false))
|
|
99
|
+
return () => {
|
|
100
|
+
alive = false
|
|
101
|
+
}
|
|
102
|
+
}, [open, entityType, entityId])
|
|
103
|
+
|
|
104
|
+
const update = (patch: Partial<SeoFieldPayload>) => setForm((f) => ({ ...f, ...patch }))
|
|
105
|
+
|
|
106
|
+
async function handleAiGenerate(field: AiField) {
|
|
107
|
+
if (!entityType || !entityId) return
|
|
108
|
+
setAiBusy(field)
|
|
109
|
+
try {
|
|
110
|
+
const result = await generateSeoField(field, { entityType, entityId })
|
|
111
|
+
if (result.unavailable) {
|
|
112
|
+
toast.error('AI is not configured. Add an AI provider to enable suggestions.')
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (result.text) {
|
|
116
|
+
update({ [field]: result.text } as Partial<SeoFieldPayload>)
|
|
117
|
+
toast.success('AI suggestion applied — review before saving.')
|
|
118
|
+
} else {
|
|
119
|
+
toast.error('No suggestion was returned.')
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
toast.error('AI request failed.')
|
|
123
|
+
} finally {
|
|
124
|
+
setAiBusy(null)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function handleSave() {
|
|
129
|
+
if (!entityType || !entityId) return
|
|
130
|
+
setSaving(true)
|
|
131
|
+
const res = await updateSeoFields(entityType, entityId, form)
|
|
132
|
+
setSaving(false)
|
|
133
|
+
if (res.error) {
|
|
134
|
+
toast.error(res.error)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
toast.success('SEO fields saved.')
|
|
138
|
+
onOpenChange(false)
|
|
139
|
+
onSaved?.()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const title = form.metaTitle || record?.title || 'Untitled'
|
|
143
|
+
const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
|
|
144
|
+
const url = record?.url ?? ''
|
|
145
|
+
|
|
146
|
+
function aiButton(field: AiField) {
|
|
147
|
+
return (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => handleAiGenerate(field)}
|
|
151
|
+
disabled={aiBusy !== null}
|
|
152
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
153
|
+
>
|
|
154
|
+
<Sparkles
|
|
155
|
+
className={`h-3.5 w-3.5 ${aiBusy === field ? 'animate-pulse' : ''}`}
|
|
156
|
+
aria-hidden
|
|
157
|
+
/>
|
|
158
|
+
{aiBusy === field ? 'Generating…' : 'AI Generate'}
|
|
159
|
+
</button>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<Drawer
|
|
165
|
+
open={open}
|
|
166
|
+
onOpenChange={onOpenChange}
|
|
167
|
+
title="Edit SEO"
|
|
168
|
+
description={record?.title}
|
|
169
|
+
footer={
|
|
170
|
+
<>
|
|
171
|
+
<button className={btnSecondary} onClick={() => onOpenChange(false)} disabled={saving}>
|
|
172
|
+
Cancel
|
|
173
|
+
</button>
|
|
174
|
+
<button className={btnPrimary} onClick={handleSave} disabled={saving || loading}>
|
|
175
|
+
{saving ? 'Saving…' : 'Save changes'}
|
|
176
|
+
</button>
|
|
177
|
+
</>
|
|
178
|
+
}
|
|
179
|
+
>
|
|
180
|
+
{loading ? (
|
|
181
|
+
<SeoLoading />
|
|
182
|
+
) : error ? (
|
|
183
|
+
<SeoErrorState message={error} />
|
|
184
|
+
) : record ? (
|
|
185
|
+
<div className="space-y-6">
|
|
186
|
+
{/* SERP preview */}
|
|
187
|
+
<div className="border-border rounded-lg border p-4">
|
|
188
|
+
<p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
|
|
189
|
+
Google preview
|
|
190
|
+
</p>
|
|
191
|
+
<p className="text-success truncate text-sm">{url}</p>
|
|
192
|
+
<p className="text-primary text-lg leading-snug">{title}</p>
|
|
193
|
+
<p className="text-muted-foreground line-clamp-2 text-sm">{desc}</p>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<Field
|
|
197
|
+
label="Meta title"
|
|
198
|
+
hint={
|
|
199
|
+
<span className="flex items-center gap-2">
|
|
200
|
+
<CharCount value={form.metaTitle ?? ''} max={META_TITLE_MAX} />
|
|
201
|
+
{aiButton('metaTitle')}
|
|
202
|
+
</span>
|
|
203
|
+
}
|
|
204
|
+
>
|
|
205
|
+
<input
|
|
206
|
+
className={inputCls}
|
|
207
|
+
value={form.metaTitle ?? ''}
|
|
208
|
+
onChange={(e) => update({ metaTitle: e.target.value })}
|
|
209
|
+
placeholder="Page title for search results"
|
|
210
|
+
/>
|
|
211
|
+
</Field>
|
|
212
|
+
|
|
213
|
+
<Field
|
|
214
|
+
label="Meta description"
|
|
215
|
+
hint={
|
|
216
|
+
<span className="flex items-center gap-2">
|
|
217
|
+
<CharCount value={form.metaDescription ?? ''} max={META_DESC_MAX} />
|
|
218
|
+
{aiButton('metaDescription')}
|
|
219
|
+
</span>
|
|
220
|
+
}
|
|
221
|
+
>
|
|
222
|
+
<textarea
|
|
223
|
+
className={`${inputCls} min-h-20 resize-y`}
|
|
224
|
+
value={form.metaDescription ?? ''}
|
|
225
|
+
onChange={(e) => update({ metaDescription: e.target.value })}
|
|
226
|
+
placeholder="Short summary shown under the title in search results"
|
|
227
|
+
/>
|
|
228
|
+
</Field>
|
|
229
|
+
|
|
230
|
+
<Field label="Focus keyword" hint={aiButton('focusKeyword')}>
|
|
231
|
+
<input
|
|
232
|
+
className={inputCls}
|
|
233
|
+
value={form.focusKeyword ?? ''}
|
|
234
|
+
onChange={(e) => update({ focusKeyword: e.target.value })}
|
|
235
|
+
placeholder="Primary keyword this page targets"
|
|
236
|
+
/>
|
|
237
|
+
</Field>
|
|
238
|
+
|
|
239
|
+
<Field label="Canonical URL">
|
|
240
|
+
<input
|
|
241
|
+
className={inputCls}
|
|
242
|
+
value={form.canonicalUrl ?? ''}
|
|
243
|
+
onChange={(e) => update({ canonicalUrl: e.target.value })}
|
|
244
|
+
placeholder={url || 'https://example.com/page'}
|
|
245
|
+
/>
|
|
246
|
+
</Field>
|
|
247
|
+
|
|
248
|
+
{/* Social preview */}
|
|
249
|
+
<div className="space-y-4">
|
|
250
|
+
<p className="text-foreground text-sm font-medium">Social sharing</p>
|
|
251
|
+
<Field label="Open Graph title" hint={aiButton('ogTitle')}>
|
|
252
|
+
<input
|
|
253
|
+
className={inputCls}
|
|
254
|
+
value={form.ogTitle ?? ''}
|
|
255
|
+
onChange={(e) => update({ ogTitle: e.target.value })}
|
|
256
|
+
placeholder={form.metaTitle || 'Title when shared on social media'}
|
|
257
|
+
/>
|
|
258
|
+
</Field>
|
|
259
|
+
<Field label="Open Graph description" hint={aiButton('ogDescription')}>
|
|
260
|
+
<textarea
|
|
261
|
+
className={`${inputCls} min-h-16 resize-y`}
|
|
262
|
+
value={form.ogDescription ?? ''}
|
|
263
|
+
onChange={(e) => update({ ogDescription: e.target.value })}
|
|
264
|
+
placeholder={form.metaDescription || 'Description when shared on social media'}
|
|
265
|
+
/>
|
|
266
|
+
</Field>
|
|
267
|
+
<div className="border-border overflow-hidden rounded-lg border">
|
|
268
|
+
{record.ogImage ? (
|
|
269
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
270
|
+
<img
|
|
271
|
+
src={record.ogImage}
|
|
272
|
+
alt={`Social preview for ${record.title}`}
|
|
273
|
+
className="bg-muted aspect-[1200/630] w-full object-cover"
|
|
274
|
+
/>
|
|
275
|
+
) : (
|
|
276
|
+
<div className="bg-muted text-muted-foreground flex aspect-[1200/630] w-full items-center justify-center text-sm">
|
|
277
|
+
No OG image set
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
280
|
+
<div className="p-3">
|
|
281
|
+
<p className="text-muted-foreground truncate text-xs uppercase">
|
|
282
|
+
{url.replace(/^https?:\/\//, '').split('/')[0]}
|
|
283
|
+
</p>
|
|
284
|
+
<p className="text-foreground truncate text-sm font-medium">
|
|
285
|
+
{form.ogTitle || title}
|
|
286
|
+
</p>
|
|
287
|
+
<p className="text-muted-foreground line-clamp-1 text-sm">
|
|
288
|
+
{form.ogDescription || desc}
|
|
289
|
+
</p>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Indexing toggles */}
|
|
295
|
+
<div className="border-border space-y-3 rounded-lg border p-4">
|
|
296
|
+
<label className="flex items-center justify-between gap-3">
|
|
297
|
+
<span className="min-w-0">
|
|
298
|
+
<span className="text-foreground block text-sm font-medium">No-index</span>
|
|
299
|
+
<span className="text-muted-foreground block text-sm">
|
|
300
|
+
Hide this page from search engines.
|
|
301
|
+
</span>
|
|
302
|
+
</span>
|
|
303
|
+
<input
|
|
304
|
+
type="checkbox"
|
|
305
|
+
role="switch"
|
|
306
|
+
aria-checked={!!form.noindex}
|
|
307
|
+
checked={!!form.noindex}
|
|
308
|
+
onChange={(e) => update({ noindex: e.target.checked })}
|
|
309
|
+
className="accent-primary h-4 w-4"
|
|
310
|
+
/>
|
|
311
|
+
</label>
|
|
312
|
+
<label className="flex items-center justify-between gap-3">
|
|
313
|
+
<span className="min-w-0">
|
|
314
|
+
<span className="text-foreground block text-sm font-medium">No-follow</span>
|
|
315
|
+
<span className="text-muted-foreground block text-sm">
|
|
316
|
+
Tell crawlers not to follow links on this page.
|
|
317
|
+
</span>
|
|
318
|
+
</span>
|
|
319
|
+
<input
|
|
320
|
+
type="checkbox"
|
|
321
|
+
role="switch"
|
|
322
|
+
aria-checked={!!form.nofollow}
|
|
323
|
+
checked={!!form.nofollow}
|
|
324
|
+
onChange={(e) => update({ nofollow: e.target.checked })}
|
|
325
|
+
className="accent-primary h-4 w-4"
|
|
326
|
+
/>
|
|
327
|
+
</label>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{/* Score summary */}
|
|
331
|
+
{(() => {
|
|
332
|
+
const meta = gradeMeta(record.seoGrade)
|
|
333
|
+
return (
|
|
334
|
+
<div
|
|
335
|
+
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm ${meta.bg} ${meta.text}`}
|
|
336
|
+
>
|
|
337
|
+
<Check className="h-4 w-4" aria-hidden />
|
|
338
|
+
Current SEO score: <span className="font-medium">{record.seoScore}</span> (
|
|
339
|
+
{meta.label})
|
|
340
|
+
</div>
|
|
341
|
+
)
|
|
342
|
+
})()}
|
|
343
|
+
</div>
|
|
344
|
+
) : null}
|
|
345
|
+
</Drawer>
|
|
346
|
+
)
|
|
347
|
+
}
|