@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,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'
@@ -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
- defaultPostHeader,
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
- interface ListEnvelope {
189
- docs: DocumentApiRow[]
190
- total: number
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. Looks up the single `post-templates` document
195
- * whose slug matches the post type. Returns an unsaved default (centered
196
- * header, empty section list) when the type has no template yet.
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<ListEnvelope>(`/collections/${TEMPLATES_COLLECTION}?pageSize=200`)
200
- const docs = res.data?.docs ?? []
201
- const match = docs.find((d) => (d.slug ?? d.data?.slug) === postType)
202
- if (!match) {
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 data = match.data ?? {}
206
- return {
207
- docId: match.id,
208
- postType,
209
- header: coercePostHeader(data.header),
210
- sections: coerceSections(data.sections),
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
- function templateWriteBody(template: EditorPostTemplate, extra?: Record<string, unknown>): string {
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 endpoint = template.docId
228
- ? `/collections/${TEMPLATES_COLLECTION}/${template.docId}`
229
- : `/collections/${TEMPLATES_COLLECTION}`
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 data = res.data.data ?? {}
236
+ const r = res.data
236
237
  return {
237
- docId: res.data.id,
238
+ docId: r.docId ?? null,
238
239
  postType: template.postType,
239
- header: coercePostHeader(data.header),
240
- sections: coerceSections(data.sections),
240
+ header: coercePostHeader(r.header),
241
+ sections: coerceSections(r.sections),
241
242
  }
242
243
  }
243
244