@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
@@ -4,8 +4,12 @@ import { bool, str, type PostRenderContext } from './parts.js'
4
4
  /**
5
5
  * Renders the post's main body. When `source === 'field'` it shows the
6
6
  * post's own Body field (passed via context); otherwise it renders the
7
- * section's own `body` copy. Body text is treated as plain paragraphs split
8
- * on blank lines — the renderer never injects raw HTML.
7
+ * section's own `body` copy.
8
+ *
9
+ * The Body field is a `richText` field (sanitized HTML on write), so this
10
+ * canvas preview renders it as HTML — exactly like the public site's
11
+ * article-body renderer — to stay WYSIWYG. It previews the author's own
12
+ * in-progress draft; the server sanitizes (sanitize-html) on save.
9
13
  */
10
14
  export function ArticleBodySection({
11
15
  section,
@@ -16,15 +20,10 @@ export function ArticleBodySection({
16
20
  }) {
17
21
  const c = section.content
18
22
  const source = str(c, 'source', 'field')
19
- const raw = source === 'field' ? (context?.body ?? '') : str(c, 'body')
23
+ const html = source === 'field' ? (context?.body ?? '') : str(c, 'body')
20
24
  const wide = str(c, 'width', 'normal') === 'wide'
21
25
  const showToc = bool(c, 'showToc')
22
26
 
23
- const paragraphs = raw
24
- .split(/\n{2,}/)
25
- .map((p) => p.trim())
26
- .filter(Boolean)
27
-
28
27
  return (
29
28
  <div className={`mx-auto px-6 ${wide ? 'max-w-4xl' : 'max-w-2xl'}`}>
30
29
  {showToc && (
@@ -37,14 +36,11 @@ export function ArticleBodySection({
37
36
  </p>
38
37
  </div>
39
38
  )}
40
- {paragraphs.length > 0 ? (
41
- <div className="space-y-4">
42
- {paragraphs.map((p, i) => (
43
- <p key={i} className="text-base leading-relaxed text-zinc-700">
44
- {p}
45
- </p>
46
- ))}
47
- </div>
39
+ {html.trim() ? (
40
+ <div
41
+ className="prose prose-zinc max-w-none text-base leading-relaxed text-zinc-700"
42
+ dangerouslySetInnerHTML={{ __html: html }}
43
+ />
48
44
  ) : (
49
45
  <p className="text-base leading-relaxed text-zinc-400 italic">
50
46
  {source === 'field'
@@ -102,6 +102,12 @@ export function PostEditorCanvas({
102
102
  }
103
103
 
104
104
  function EmptyBody({ onAddSection }: { onAddSection: () => void }) {
105
+ // This block sits inside the fixed-white preview surface, so the neutral
106
+ // greys intentionally match the website-preview palette used by PostHeader
107
+ // and the section renderers (not the admin theme tokens, which flip in dark
108
+ // mode and would lose contrast on the always-white surface). The CTA is the
109
+ // one true admin affordance here, so it uses the brand `primary` token,
110
+ // which carries a guaranteed-contrast foreground in both modes.
105
111
  return (
106
112
  <div className="flex flex-col items-center justify-center gap-3 border-t border-zinc-100 px-6 py-16 text-center">
107
113
  <span className="flex h-12 w-12 items-center justify-center rounded-full bg-zinc-100">
@@ -113,7 +119,7 @@ function EmptyBody({ onAddSection }: { onAddSection: () => void }) {
113
119
  <button
114
120
  type="button"
115
121
  onClick={onAddSection}
116
- className="inline-flex items-center gap-2 rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
122
+ className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
117
123
  >
118
124
  <Plus className="h-4 w-4" aria-hidden />
119
125
  Add Section
@@ -3,6 +3,8 @@
3
3
  import { useEffect, useState } from 'react'
4
4
  import { Modal } from '../../components/ui/Modal.js'
5
5
  import { Button } from '../../components/ui/Button.js'
6
+ import { MediaPickerModal } from '../../components/MediaPickerModal.js'
7
+ import { RichTextField } from '../../fields/RichTextField.js'
6
8
  import type { EditorPost } from '../../lib/post-editor-service.js'
7
9
 
8
10
  interface PostFieldsModalProps {
@@ -36,6 +38,7 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
36
38
  const [seoTitle, setSeoTitle] = useState(post.seoTitle)
37
39
  const [seoDescription, setSeoDescription] = useState(post.seoDescription)
38
40
  const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
41
+ const [mediaOpen, setMediaOpen] = useState(false)
39
42
 
40
43
  useEffect(() => {
41
44
  if (!open) return
@@ -134,16 +137,26 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
134
137
  </div>
135
138
  <div>
136
139
  <label className={LABEL} htmlFor="pf-image">
137
- Featured image URL
140
+ Featured image
138
141
  </label>
139
- <input
140
- id="pf-image"
141
- className={INPUT}
142
- value={featuredImage}
143
- disabled={!canEdit}
144
- placeholder="https://…"
145
- onChange={(e) => setFeaturedImage(e.target.value)}
146
- />
142
+ <div className="flex gap-2">
143
+ <input
144
+ id="pf-image"
145
+ className={`${INPUT} flex-1`}
146
+ value={featuredImage}
147
+ disabled={!canEdit}
148
+ placeholder="https://… or pick from the media library"
149
+ onChange={(e) => setFeaturedImage(e.target.value)}
150
+ />
151
+ <button
152
+ type="button"
153
+ disabled={!canEdit}
154
+ onClick={() => setMediaOpen(true)}
155
+ className="bg-accent text-foreground border-border hover:bg-accent/80 shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium disabled:opacity-50"
156
+ >
157
+ Browse
158
+ </button>
159
+ </div>
147
160
  {featuredImage && (
148
161
  <div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
149
162
  <img src={featuredImage} alt="" className="h-28 w-full object-cover" />
@@ -151,20 +164,12 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
151
164
  )}
152
165
  </div>
153
166
  <div>
154
- <label className={LABEL} htmlFor="pf-body">
155
- Body
156
- </label>
157
- <textarea
158
- id="pf-body"
159
- className={`${INPUT} min-h-[120px] resize-y`}
167
+ <RichTextField
168
+ label="Body"
160
169
  value={body}
161
- disabled={!canEdit}
162
- placeholder="The main post content. Shown by the Article Body section."
163
- onChange={(e) => setBody(e.target.value)}
170
+ onChange={canEdit ? setBody : () => {}}
171
+ helpText="Rendered by the Article Body section when its source is set to the post Body field."
164
172
  />
165
- <p className="text-muted-foreground mt-1 text-xs">
166
- Rendered by the Article Body section when its source is set to the post Body field.
167
- </p>
168
173
  </div>
169
174
  <div>
170
175
  <label className={LABEL} htmlFor="pf-seo-title">
@@ -191,6 +196,16 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
191
196
  />
192
197
  </div>
193
198
  </div>
199
+
200
+ <MediaPickerModal
201
+ open={mediaOpen}
202
+ onClose={() => setMediaOpen(false)}
203
+ onSelect={(url) => {
204
+ setFeaturedImage(url)
205
+ setMediaOpen(false)
206
+ }}
207
+ accept="image/*"
208
+ />
194
209
  </Modal>
195
210
  )
196
211
  }
@@ -1,3 +1,4 @@
1
+ import { computeReadingTime } from '@actuate-media/cms-core/page-sections'
1
2
  import type { PostHeaderConfig } from '../page-editor/section-types.js'
2
3
  import type { PostRenderContext } from '../page-editor/sections/index.js'
3
4
 
@@ -37,7 +38,7 @@ export function PostHeader({ config, context }: PostHeaderProps) {
37
38
  )}
38
39
  {config.showAuthor && <span>{context.author?.name || 'Author name'}</span>}
39
40
  {date && <span>{date}</span>}
40
- {config.showReadingTime && <span>5 min read</span>}
41
+ {config.showReadingTime && <span>{computeReadingTime(context.body)} min read</span>}
41
42
  </div>
42
43
  )
43
44
 
@@ -45,7 +46,7 @@ export function PostHeader({ config, context }: PostHeaderProps) {
45
46
  return (
46
47
  <header className="relative">
47
48
  <img src={context.featuredImageUrl} alt="" className="h-[360px] w-full object-cover" />
48
- <div className="absolute inset-0 bg-gradient-to-t from-black/70 to-black/10" aria-hidden />
49
+ <div className="absolute inset-0 bg-linear-to-t from-black/70 to-black/10" aria-hidden />
49
50
  <div className="absolute inset-x-0 bottom-0 px-6 pb-8">
50
51
  <div className={`mx-auto flex max-w-3xl flex-col ${align}`}>
51
52
  <h1 className="text-3xl font-semibold tracking-tight text-white sm:text-4xl">
@@ -66,7 +67,6 @@ export function PostHeader({ config, context }: PostHeaderProps) {
66
67
  <div
67
68
  className={`flex max-w-3xl flex-col ${align} ${config.layout === 'centered' ? 'mx-auto' : ''}`}
68
69
  >
69
- {config.showCategory && context.category && config.layout !== 'centered' && null}
70
70
  <h1 className="text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl">{title}</h1>
71
71
  {config.showExcerpt && context.excerpt && (
72
72
  <p className="mt-3 max-w-2xl text-lg text-zinc-600">{context.excerpt}</p>
@@ -78,7 +78,7 @@ export function PostHeader({ config, context }: PostHeaderProps) {
78
78
  <img
79
79
  src={context.featuredImageUrl}
80
80
  alt=""
81
- className="aspect-[16/9] w-full rounded-xl object-cover"
81
+ className="aspect-video w-full rounded-xl object-cover"
82
82
  />
83
83
  </div>
84
84
  )}
@@ -78,6 +78,17 @@ export function PostPreview({ postType, documentId, session, onNavigate }: PostP
78
78
  }
79
79
  const visible = post.sections.filter((s) => s.visible)
80
80
 
81
+ // `window.close()` only works for tabs JS opened (the "View preview" action
82
+ // opens this view via window.open). When the user reached it through in-app
83
+ // navigation there is no opener, so close() is a silent no-op — fall back to
84
+ // the editor after a tick so the Close button always does something.
85
+ const handleClose = () => {
86
+ window.close()
87
+ setTimeout(() => {
88
+ onNavigate(`/posts/${postType}/${documentId}/edit`)
89
+ }, 120)
90
+ }
91
+
81
92
  return (
82
93
  <div className="flex h-full flex-col">
83
94
  {/* Slim preview bar. This view opens in its own browser tab, so
@@ -85,7 +96,7 @@ export function PostPreview({ postType, documentId, session, onNavigate }: PostP
85
96
  <div className="bg-card border-border flex h-12 shrink-0 items-center justify-between border-b px-4">
86
97
  <button
87
98
  type="button"
88
- onClick={() => window.close()}
99
+ onClick={handleClose}
89
100
  className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm"
90
101
  aria-label="Close preview tab"
91
102
  >
@@ -43,12 +43,23 @@ import { ValidationSummary } from '../page-editor/ValidationSummary.js'
43
43
  import { PostEditorCanvas } from './PostEditorCanvas.js'
44
44
  import { PostFieldsModal } from './PostFieldsModal.js'
45
45
 
46
+ /** The slice of the resolved CMS config this editor reads. */
47
+ interface EditorConfig {
48
+ collections?: Record<string, { labels?: { singular?: string }; urlPrefix?: string } | undefined>
49
+ seo?: { siteUrl?: string }
50
+ }
51
+
52
+ /** The slice of the admin session this editor reads. */
53
+ interface EditorSession {
54
+ user?: { role?: string; name?: string; email?: string }
55
+ }
56
+
46
57
  interface PostSectionEditorProps {
47
58
  postType: string
48
59
  /** undefined when creating a new post (`/posts/:type/new`) */
49
60
  documentId?: string
50
- config?: any
51
- session?: any
61
+ config?: EditorConfig
62
+ session?: EditorSession
52
63
  onNavigate: (path: string) => void
53
64
  /**
54
65
  * Open a preview route in a new browser tab. `prepare` runs before the tab
@@ -58,9 +69,15 @@ interface PostSectionEditorProps {
58
69
  onOpenPreview?: (path: string, prepare?: () => Promise<boolean | void> | boolean | void) => void
59
70
  }
60
71
 
61
- function deriveCan(session: any): { canEdit: boolean; canPublish: boolean } {
62
- const role: string | undefined = session?.user?.role
63
- if (!role) return { canEdit: true, canPublish: true }
72
+ function deriveCan(session: EditorSession | undefined): {
73
+ canEdit: boolean
74
+ canPublish: boolean
75
+ } {
76
+ const role = session?.user?.role
77
+ // Default closed: when we can't identify a role, hide the edit/publish
78
+ // affordances rather than exposing them. The server independently enforces
79
+ // access on every write, so this is a UX guard — but it should fail safe.
80
+ if (!role) return { canEdit: false, canPublish: false }
64
81
  const editors = ['ADMIN', 'EDITOR', 'AUTHOR']
65
82
  const publishers = ['ADMIN', 'EDITOR']
66
83
  return { canEdit: editors.includes(role), canPublish: publishers.includes(role) }
@@ -0,0 +1,229 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { ClipboardCheck, Clock, CheckCircle2 } from 'lucide-react'
5
+ import {
6
+ fetchSeoIssues,
7
+ fetchSeoAuditRuns,
8
+ type SeoIssue,
9
+ type SeoIssueQuery,
10
+ type SeoSeverity,
11
+ type SeoIssueStatus,
12
+ } from '../../lib/seo-service.js'
13
+ import {
14
+ SectionCard,
15
+ SeoLoading,
16
+ SeoEmptyState,
17
+ SeoErrorState,
18
+ severityMeta,
19
+ SeoStatusBadge,
20
+ } from '../../components/seo/primitives.js'
21
+ import { SeoIssueFixDrawer } from '../../components/seo/SeoIssueFixDrawer.js'
22
+ import { useSeoResource } from './useSeoResource.js'
23
+
24
+ const SEVERITY_FILTERS: { id: SeoSeverity | 'all'; label: string }[] = [
25
+ { id: 'all', label: 'All' },
26
+ { id: 'critical', label: 'Critical' },
27
+ { id: 'warning', label: 'Warnings' },
28
+ { id: 'info', label: 'Info' },
29
+ ]
30
+
31
+ const STATUS_FILTERS: { id: SeoIssueStatus | 'all'; label: string }[] = [
32
+ { id: 'open', label: 'Open' },
33
+ { id: 'recurring', label: 'Recurring' },
34
+ { id: 'ignored', label: 'Ignored' },
35
+ { id: 'resolved', label: 'Resolved' },
36
+ { id: 'all', label: 'All' },
37
+ ]
38
+
39
+ function issueStatusTone(status: SeoIssueStatus) {
40
+ switch (status) {
41
+ case 'resolved':
42
+ return 'good' as const
43
+ case 'ignored':
44
+ return 'neutral' as const
45
+ case 'recurring':
46
+ return 'fair' as const
47
+ default:
48
+ return 'critical' as const
49
+ }
50
+ }
51
+
52
+ function AuditSummaryCards({ refetchKey }: { refetchKey: number }) {
53
+ const { data, loading } = useSeoResource(fetchSeoAuditRuns, [refetchKey])
54
+ const last = (data ?? [])[0]
55
+ return (
56
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
57
+ <div className="border-border bg-card rounded-lg border p-4">
58
+ <p className="text-muted-foreground flex items-center gap-1.5 text-sm">
59
+ <Clock className="h-3.5 w-3.5" aria-hidden /> Last audit
60
+ </p>
61
+ <p className="text-foreground mt-1 text-sm font-medium">
62
+ {loading ? '…' : last ? new Date(last.startedAt).toLocaleString() : 'Never'}
63
+ </p>
64
+ </div>
65
+ <div className="border-border bg-card rounded-lg border p-4">
66
+ <p className="text-muted-foreground text-sm">Pages scanned</p>
67
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
68
+ {last?.pagesScanned ?? 0}
69
+ </p>
70
+ </div>
71
+ <div className="border-border bg-card rounded-lg border p-4">
72
+ <p className="text-muted-foreground text-sm">Issues found</p>
73
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
74
+ {last?.issuesFound ?? 0}
75
+ </p>
76
+ </div>
77
+ <div className="border-border bg-card rounded-lg border p-4">
78
+ <p className="text-muted-foreground text-sm">Site score</p>
79
+ <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
80
+ {last?.siteScoreAfter ?? '—'}
81
+ </p>
82
+ </div>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ export function AuditTab({
88
+ refetchKey = 0,
89
+ initialIssueId,
90
+ onNavigate,
91
+ }: {
92
+ refetchKey?: number
93
+ initialIssueId?: string | null
94
+ onNavigate?: (path: string) => void
95
+ }) {
96
+ const [severity, setSeverity] = useState<SeoSeverity | 'all'>('all')
97
+ const [status, setStatus] = useState<SeoIssueStatus | 'all'>('open')
98
+ const [openIssue, setOpenIssue] = useState<string | null>(initialIssueId ?? null)
99
+
100
+ const query: SeoIssueQuery = useMemo(() => ({ severity, status }), [severity, status])
101
+ const { data, loading, error, refetch } = useSeoResource(
102
+ () => fetchSeoIssues(query),
103
+ [severity, status, refetchKey],
104
+ )
105
+
106
+ const issues = data ?? []
107
+
108
+ return (
109
+ <div className="space-y-4">
110
+ <AuditSummaryCards refetchKey={refetchKey} />
111
+
112
+ <SectionCard>
113
+ <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
114
+ <div className="flex flex-wrap gap-1" role="tablist" aria-label="Filter by severity">
115
+ {SEVERITY_FILTERS.map((f) => (
116
+ <button
117
+ key={f.id}
118
+ role="tab"
119
+ aria-selected={severity === f.id}
120
+ onClick={() => setSeverity(f.id)}
121
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
122
+ severity === f.id
123
+ ? 'bg-primary text-primary-foreground'
124
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
125
+ }`}
126
+ >
127
+ {f.label}
128
+ </button>
129
+ ))}
130
+ </div>
131
+ <div className="flex flex-wrap gap-1" role="tablist" aria-label="Filter by status">
132
+ {STATUS_FILTERS.map((f) => (
133
+ <button
134
+ key={f.id}
135
+ role="tab"
136
+ aria-selected={status === f.id}
137
+ onClick={() => setStatus(f.id)}
138
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
139
+ status === f.id
140
+ ? 'bg-accent text-foreground'
141
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
142
+ }`}
143
+ >
144
+ {f.label}
145
+ </button>
146
+ ))}
147
+ </div>
148
+ </div>
149
+
150
+ {loading ? (
151
+ <SeoLoading label="Loading issues…" />
152
+ ) : error ? (
153
+ <SeoErrorState message={error} onRetry={refetch} />
154
+ ) : issues.length === 0 ? (
155
+ <SeoEmptyState
156
+ icon={status === 'open' ? <CheckCircle2 size={24} /> : <ClipboardCheck size={24} />}
157
+ title={status === 'open' ? 'No open issues' : 'No issues match'}
158
+ description={
159
+ status === 'open'
160
+ ? 'Run an audit to scan for new issues, or adjust the filters.'
161
+ : 'Try a different severity or status filter.'
162
+ }
163
+ />
164
+ ) : (
165
+ <div className="overflow-x-auto">
166
+ <table className="w-full text-sm" aria-label="SEO issues">
167
+ <thead>
168
+ <tr className="text-muted-foreground border-border border-b text-left">
169
+ <th className="py-2 pr-4 font-medium">Severity</th>
170
+ <th className="py-2 pr-4 font-medium">Issue</th>
171
+ <th className="py-2 pr-4 font-medium">Page</th>
172
+ <th className="py-2 pr-4 font-medium">Status</th>
173
+ <th className="py-2 text-right font-medium">
174
+ <span className="sr-only">Actions</span>
175
+ </th>
176
+ </tr>
177
+ </thead>
178
+ <tbody className="divide-border divide-y">
179
+ {issues.map((issue: SeoIssue) => {
180
+ const meta = severityMeta(issue.severity)
181
+ return (
182
+ <tr key={issue.id} className="hover:bg-accent/40">
183
+ <td className="py-2.5 pr-4">
184
+ <span className={`inline-flex items-center gap-1.5 text-sm ${meta.text}`}>
185
+ <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
186
+ {meta.label}
187
+ </span>
188
+ </td>
189
+ <td className="py-2.5 pr-4">
190
+ <span className="text-foreground block font-medium">{issue.title}</span>
191
+ <span className="text-muted-foreground block capitalize">
192
+ {issue.category}
193
+ </span>
194
+ </td>
195
+ <td className="text-muted-foreground py-2.5 pr-4">
196
+ <span className="block truncate">
197
+ {issue.entityTitle || issue.url || '—'}
198
+ </span>
199
+ </td>
200
+ <td className="py-2.5 pr-4">
201
+ <SeoStatusBadge label={issue.status} tone={issueStatusTone(issue.status)} />
202
+ </td>
203
+ <td className="py-2.5 text-right">
204
+ <button
205
+ onClick={() => setOpenIssue(issue.id)}
206
+ className="text-primary hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
207
+ >
208
+ {issue.fixable ? 'Fix' : 'View'}
209
+ </button>
210
+ </td>
211
+ </tr>
212
+ )
213
+ })}
214
+ </tbody>
215
+ </table>
216
+ </div>
217
+ )}
218
+ </SectionCard>
219
+
220
+ <SeoIssueFixDrawer
221
+ open={openIssue !== null}
222
+ onOpenChange={(o) => !o && setOpenIssue(null)}
223
+ issueId={openIssue}
224
+ onChanged={refetch}
225
+ onNavigate={onNavigate}
226
+ />
227
+ </div>
228
+ )
229
+ }
@@ -0,0 +1,164 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { Search, FileText, Pencil } from 'lucide-react'
5
+ import {
6
+ fetchSeoContentRecords,
7
+ applyContentSeoQuery,
8
+ type ContentSeoFilter,
9
+ } from '../../lib/seo-service.js'
10
+ import {
11
+ SectionCard,
12
+ SeoLoading,
13
+ SeoEmptyState,
14
+ SeoErrorState,
15
+ ScorePill,
16
+ SeoStatusBadge,
17
+ } from '../../components/seo/primitives.js'
18
+ import { SeoEditorDrawer } from '../../components/seo/SeoEditorDrawer.js'
19
+ import { useSeoResource } from './useSeoResource.js'
20
+
21
+ const FILTERS: { id: ContentSeoFilter; label: string }[] = [
22
+ { id: 'all', label: 'All' },
23
+ { id: 'pages', label: 'Pages' },
24
+ { id: 'posts', label: 'Posts' },
25
+ ]
26
+
27
+ const inputCls =
28
+ 'w-full rounded-md border border-border bg-input-background py-2 pl-9 pr-3 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
29
+
30
+ export function ContentTab({
31
+ initialEntityType,
32
+ initialEntityId,
33
+ }: {
34
+ initialEntityType?: string | null
35
+ initialEntityId?: string | null
36
+ }) {
37
+ const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
38
+ const [search, setSearch] = useState('')
39
+ const [filter, setFilter] = useState<ContentSeoFilter>('all')
40
+ const [editor, setEditor] = useState<{ type: string; id: string } | null>(
41
+ initialEntityType && initialEntityId ? { type: initialEntityType, id: initialEntityId } : null,
42
+ )
43
+
44
+ const rows = useMemo(
45
+ () => applyContentSeoQuery(data ?? [], { search, filter }),
46
+ [data, search, filter],
47
+ )
48
+
49
+ return (
50
+ <div className="space-y-4">
51
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
52
+ <div className="relative max-w-sm flex-1">
53
+ <Search
54
+ className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
55
+ aria-hidden
56
+ />
57
+ <input
58
+ className={inputCls}
59
+ placeholder="Search content…"
60
+ value={search}
61
+ onChange={(e) => setSearch(e.target.value)}
62
+ aria-label="Search content"
63
+ />
64
+ </div>
65
+ <div className="flex gap-1" role="tablist" aria-label="Content type filter">
66
+ {FILTERS.map((f) => (
67
+ <button
68
+ key={f.id}
69
+ role="tab"
70
+ aria-selected={filter === f.id}
71
+ onClick={() => setFilter(f.id)}
72
+ className={`focus-visible:ring-ring rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none ${
73
+ filter === f.id
74
+ ? 'bg-primary text-primary-foreground'
75
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
76
+ }`}
77
+ >
78
+ {f.label}
79
+ </button>
80
+ ))}
81
+ </div>
82
+ </div>
83
+
84
+ <SectionCard>
85
+ {loading ? (
86
+ <SeoLoading label="Loading content…" />
87
+ ) : error ? (
88
+ <SeoErrorState message={error} onRetry={refetch} />
89
+ ) : rows.length === 0 ? (
90
+ <SeoEmptyState
91
+ icon={<FileText size={24} />}
92
+ title={search || filter !== 'all' ? 'No matching content' : 'No content yet'}
93
+ description={
94
+ search || filter !== 'all'
95
+ ? 'Try adjusting your search or filter.'
96
+ : 'Publish pages or posts to manage their SEO here.'
97
+ }
98
+ />
99
+ ) : (
100
+ <div className="overflow-x-auto">
101
+ <table className="w-full text-sm" aria-label="Content SEO">
102
+ <thead>
103
+ <tr className="text-muted-foreground border-border border-b text-left">
104
+ <th className="py-2 pr-4 font-medium">Title</th>
105
+ <th className="py-2 pr-4 font-medium">Type</th>
106
+ <th className="py-2 pr-4 font-medium">Meta</th>
107
+ <th className="py-2 pr-4 text-right font-medium">Score</th>
108
+ <th className="py-2 text-right font-medium">
109
+ <span className="sr-only">Actions</span>
110
+ </th>
111
+ </tr>
112
+ </thead>
113
+ <tbody className="divide-border divide-y">
114
+ {rows.map((r) => (
115
+ <tr key={`${r.entityType}-${r.entityId}`} className="hover:bg-accent/40">
116
+ <td className="py-2.5 pr-4">
117
+ <span className="text-foreground block font-medium">{r.title}</span>
118
+ <span className="text-muted-foreground block truncate">{r.url}</span>
119
+ </td>
120
+ <td className="py-2.5 pr-4 capitalize">
121
+ <span className="text-muted-foreground">{r.entityType}</span>
122
+ </td>
123
+ <td className="py-2.5 pr-4">
124
+ <span className="flex flex-wrap gap-1.5">
125
+ <SeoStatusBadge
126
+ label={r.metaTitle ? 'Title' : 'No title'}
127
+ tone={r.metaTitle ? 'good' : 'critical'}
128
+ />
129
+ <SeoStatusBadge
130
+ label={r.metaDescription ? 'Desc' : 'No desc'}
131
+ tone={r.metaDescription ? 'good' : 'critical'}
132
+ />
133
+ {r.noindex && <SeoStatusBadge label="No-index" tone="fair" />}
134
+ </span>
135
+ </td>
136
+ <td className="py-2.5 pr-4 text-right">
137
+ <ScorePill score={r.seoScore} />
138
+ </td>
139
+ <td className="py-2.5 text-right">
140
+ <button
141
+ onClick={() => setEditor({ type: r.entityType, id: r.entityId })}
142
+ className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm focus-visible:ring-2 focus-visible:outline-none"
143
+ >
144
+ <Pencil className="h-3.5 w-3.5" aria-hidden /> Edit
145
+ </button>
146
+ </td>
147
+ </tr>
148
+ ))}
149
+ </tbody>
150
+ </table>
151
+ </div>
152
+ )}
153
+ </SectionCard>
154
+
155
+ <SeoEditorDrawer
156
+ open={editor !== null}
157
+ onOpenChange={(o) => !o && setEditor(null)}
158
+ entityType={editor?.type ?? null}
159
+ entityId={editor?.id ?? null}
160
+ onSaved={refetch}
161
+ />
162
+ </div>
163
+ )
164
+ }