@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.
Files changed (167) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +9 -5
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/lib/post-editor-service.test.js +75 -23
  5. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  6. package/dist/__tests__/lib/posts-service.test.js +132 -142
  7. package/dist/__tests__/lib/posts-service.test.js.map +1 -1
  8. package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
  11. package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/seo/Drawer.d.ts +11 -0
  14. package/dist/components/seo/Drawer.d.ts.map +1 -0
  15. package/dist/components/seo/Drawer.js +13 -0
  16. package/dist/components/seo/Drawer.js.map +1 -0
  17. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  18. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  19. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  20. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  21. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  22. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  23. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  24. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  25. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  26. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  27. package/dist/components/seo/SeoEditorDrawer.js +111 -0
  28. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  29. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  30. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  31. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  32. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  33. package/dist/components/seo/primitives.d.ts +53 -0
  34. package/dist/components/seo/primitives.d.ts.map +1 -0
  35. package/dist/components/seo/primitives.js +100 -0
  36. package/dist/components/seo/primitives.js.map +1 -0
  37. package/dist/index.d.ts +0 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +0 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/layout/Sidebar.d.ts.map +1 -1
  42. package/dist/layout/Sidebar.js +3 -0
  43. package/dist/layout/Sidebar.js.map +1 -1
  44. package/dist/lib/post-editor-service.d.ts +6 -4
  45. package/dist/lib/post-editor-service.d.ts.map +1 -1
  46. package/dist/lib/post-editor-service.js +32 -37
  47. package/dist/lib/post-editor-service.js.map +1 -1
  48. package/dist/lib/posts-service.d.ts.map +1 -1
  49. package/dist/lib/posts-service.js +91 -160
  50. package/dist/lib/posts-service.js.map +1 -1
  51. package/dist/lib/seo-service.d.ts +322 -0
  52. package/dist/lib/seo-service.d.ts.map +1 -0
  53. package/dist/lib/seo-service.js +295 -0
  54. package/dist/lib/seo-service.js.map +1 -0
  55. package/dist/router/index.d.ts.map +1 -1
  56. package/dist/router/index.js +4 -1
  57. package/dist/router/index.js.map +1 -1
  58. package/dist/views/MediaBrowser.d.ts.map +1 -1
  59. package/dist/views/MediaBrowser.js +36 -25
  60. package/dist/views/MediaBrowser.js.map +1 -1
  61. package/dist/views/Posts/NewPostPicker.d.ts +5 -4
  62. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
  63. package/dist/views/Posts/NewPostPicker.js +5 -4
  64. package/dist/views/Posts/NewPostPicker.js.map +1 -1
  65. package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
  66. package/dist/views/Posts/PostTypesView.d.ts +5 -4
  67. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  68. package/dist/views/Posts/PostTypesView.js.map +1 -1
  69. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  70. package/dist/views/Posts/PostsListView.js +8 -0
  71. package/dist/views/Posts/PostsListView.js.map +1 -1
  72. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  73. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  74. package/dist/views/Posts/post-type-presets.js +10 -64
  75. package/dist/views/Posts/post-type-presets.js.map +1 -1
  76. package/dist/views/SEO.d.ts.map +1 -1
  77. package/dist/views/SEO.js +89 -132
  78. package/dist/views/SEO.js.map +1 -1
  79. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
  80. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  81. package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
  82. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  83. package/dist/views/post-editor/PostEditorCanvas.js +7 -1
  84. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  85. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  86. package/dist/views/post-editor/PostFieldsModal.js +10 -4
  87. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  88. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  89. package/dist/views/post-editor/PostHeader.js +4 -3
  90. package/dist/views/post-editor/PostHeader.js.map +1 -1
  91. package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
  92. package/dist/views/post-editor/PostPreview.js +11 -1
  93. package/dist/views/post-editor/PostPreview.js.map +1 -1
  94. package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
  95. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  96. package/dist/views/post-editor/PostSectionEditor.js +4 -1
  97. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  98. package/dist/views/seo/AuditTab.d.ts +6 -0
  99. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  100. package/dist/views/seo/AuditTab.js +57 -0
  101. package/dist/views/seo/AuditTab.js.map +1 -0
  102. package/dist/views/seo/ContentTab.d.ts +5 -0
  103. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  104. package/dist/views/seo/ContentTab.js +27 -0
  105. package/dist/views/seo/ContentTab.js.map +1 -0
  106. package/dist/views/seo/OverviewTab.d.ts +8 -0
  107. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  108. package/dist/views/seo/OverviewTab.js +35 -0
  109. package/dist/views/seo/OverviewTab.js.map +1 -0
  110. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  111. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  112. package/dist/views/seo/RedirectsTab.js +124 -0
  113. package/dist/views/seo/RedirectsTab.js.map +1 -0
  114. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  115. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  116. package/dist/views/seo/TechnicalTab.js +102 -0
  117. package/dist/views/seo/TechnicalTab.js.map +1 -0
  118. package/dist/views/seo/useSeoResource.d.ts +8 -0
  119. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  120. package/dist/views/seo/useSeoResource.js +42 -0
  121. package/dist/views/seo/useSeoResource.js.map +1 -0
  122. package/package.json +2 -2
  123. package/src/AdminRoot.tsx +9 -5
  124. package/src/__tests__/lib/post-editor-service.test.ts +78 -23
  125. package/src/__tests__/lib/posts-service.test.ts +134 -163
  126. package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
  127. package/src/components/seo/Drawer.tsx +61 -0
  128. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  129. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  130. package/src/components/seo/SeoEditorDrawer.tsx +347 -0
  131. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  132. package/src/components/seo/primitives.tsx +237 -0
  133. package/src/index.ts +0 -2
  134. package/src/layout/Sidebar.tsx +3 -0
  135. package/src/lib/post-editor-service.ts +41 -40
  136. package/src/lib/posts-service.ts +109 -180
  137. package/src/lib/seo-service.ts +623 -0
  138. package/src/router/index.ts +4 -1
  139. package/src/views/MediaBrowser.tsx +89 -67
  140. package/src/views/Posts/NewPostPicker.tsx +5 -4
  141. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  142. package/src/views/Posts/PostTypesView.tsx +5 -4
  143. package/src/views/Posts/PostsListView.tsx +9 -0
  144. package/src/views/Posts/post-type-presets.ts +15 -64
  145. package/src/views/SEO.tsx +186 -863
  146. package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
  147. package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
  148. package/src/views/post-editor/PostFieldsModal.tsx +36 -21
  149. package/src/views/post-editor/PostHeader.tsx +4 -4
  150. package/src/views/post-editor/PostPreview.tsx +12 -1
  151. package/src/views/post-editor/PostSectionEditor.tsx +22 -5
  152. package/src/views/seo/AuditTab.tsx +229 -0
  153. package/src/views/seo/ContentTab.tsx +164 -0
  154. package/src/views/seo/OverviewTab.tsx +257 -0
  155. package/src/views/seo/RedirectsTab.tsx +392 -0
  156. package/src/views/seo/TechnicalTab.tsx +303 -0
  157. package/src/views/seo/useSeoResource.ts +52 -0
  158. package/dist/views/PostEditor.d.ts +0 -6
  159. package/dist/views/PostEditor.d.ts.map +0 -1
  160. package/dist/views/PostEditor.js +0 -79
  161. package/dist/views/PostEditor.js.map +0 -1
  162. package/dist/views/Posts.d.ts +0 -5
  163. package/dist/views/Posts.d.ts.map +0 -1
  164. package/dist/views/Posts.js +0 -73
  165. package/dist/views/Posts.js.map +0 -1
  166. package/src/views/PostEditor.tsx +0 -257
  167. 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
+ }