@actuate-media/cms-admin 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +9 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +75 -23
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/posts-service.test.js +132 -142
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/seo/Drawer.d.ts +11 -0
- package/dist/components/seo/Drawer.d.ts.map +1 -0
- package/dist/components/seo/Drawer.js +13 -0
- package/dist/components/seo/Drawer.js.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.js +94 -0
- package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.js +34 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.js +111 -0
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
- package/dist/components/seo/primitives.d.ts +53 -0
- package/dist/components/seo/primitives.d.ts.map +1 -0
- package/dist/components/seo/primitives.js +100 -0
- package/dist/components/seo/primitives.js.map +1 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -0
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +32 -37
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +91 -160
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +322 -0
- package/dist/lib/seo-service.d.ts.map +1 -0
- package/dist/lib/seo-service.js +295 -0
- package/dist/lib/seo-service.js.map +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +4 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +36 -25
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/NewPostPicker.d.ts +5 -4
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
- package/dist/views/Posts/NewPostPicker.js +5 -4
- package/dist/views/Posts/NewPostPicker.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
- package/dist/views/Posts/PostTypesView.d.ts +5 -4
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +8 -0
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/Posts/post-type-presets.d.ts +5 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
- package/dist/views/Posts/post-type-presets.js +10 -64
- package/dist/views/Posts/post-type-presets.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +89 -132
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +7 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +10 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +4 -3
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
- package/dist/views/post-editor/PostPreview.js +11 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts +6 -0
- package/dist/views/seo/AuditTab.d.ts.map +1 -0
- package/dist/views/seo/AuditTab.js +57 -0
- package/dist/views/seo/AuditTab.js.map +1 -0
- package/dist/views/seo/ContentTab.d.ts +5 -0
- package/dist/views/seo/ContentTab.d.ts.map +1 -0
- package/dist/views/seo/ContentTab.js +27 -0
- package/dist/views/seo/ContentTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.d.ts +8 -0
- package/dist/views/seo/OverviewTab.d.ts.map +1 -0
- package/dist/views/seo/OverviewTab.js +35 -0
- package/dist/views/seo/OverviewTab.js.map +1 -0
- package/dist/views/seo/RedirectsTab.d.ts +2 -0
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
- package/dist/views/seo/RedirectsTab.js +124 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -0
- package/dist/views/seo/TechnicalTab.d.ts +2 -0
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
- package/dist/views/seo/TechnicalTab.js +102 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -0
- package/dist/views/seo/useSeoResource.d.ts +8 -0
- package/dist/views/seo/useSeoResource.d.ts.map +1 -0
- package/dist/views/seo/useSeoResource.js +42 -0
- package/dist/views/seo/useSeoResource.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +9 -5
- package/src/__tests__/lib/post-editor-service.test.ts +78 -23
- package/src/__tests__/lib/posts-service.test.ts +134 -163
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- package/src/components/seo/Drawer.tsx +61 -0
- package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
- package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
- package/src/components/seo/SeoEditorDrawer.tsx +347 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
- package/src/components/seo/primitives.tsx +237 -0
- package/src/index.ts +0 -2
- package/src/layout/Sidebar.tsx +3 -0
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- package/src/lib/seo-service.ts +623 -0
- package/src/router/index.ts +4 -1
- package/src/views/MediaBrowser.tsx +89 -67
- package/src/views/Posts/NewPostPicker.tsx +5 -4
- package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/Posts/PostsListView.tsx +9 -0
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
- package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
- package/src/views/post-editor/PostFieldsModal.tsx +36 -21
- package/src/views/post-editor/PostHeader.tsx +4 -4
- package/src/views/post-editor/PostPreview.tsx +12 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -5
- package/src/views/seo/AuditTab.tsx +229 -0
- package/src/views/seo/ContentTab.tsx +164 -0
- package/src/views/seo/OverviewTab.tsx +257 -0
- package/src/views/seo/RedirectsTab.tsx +392 -0
- package/src/views/seo/TechnicalTab.tsx +303 -0
- package/src/views/seo/useSeoResource.ts +52 -0
- package/dist/views/PostEditor.d.ts +0 -6
- package/dist/views/PostEditor.d.ts.map +0 -1
- package/dist/views/PostEditor.js +0 -79
- package/dist/views/PostEditor.js.map +0 -1
- package/dist/views/Posts.d.ts +0 -5
- package/dist/views/Posts.d.ts.map +0 -1
- package/dist/views/Posts.js +0 -73
- package/dist/views/Posts.js.map +0 -1
- package/src/views/PostEditor.tsx +0 -257
- package/src/views/Posts.tsx +0 -375
|
@@ -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.
|
|
8
|
-
*
|
|
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
|
|
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
|
-
{
|
|
41
|
-
<div
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
140
|
+
Featured image
|
|
138
141
|
</label>
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
162
|
-
|
|
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>
|
|
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-
|
|
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-
|
|
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={
|
|
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?:
|
|
51
|
-
session?:
|
|
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:
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|