@actuate-media/cms-admin 0.74.0 → 0.76.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/CHANGELOG.md +18 -0
- package/dist/__tests__/lib/post-editor-service.test.js +2 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +12 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.js +5 -6
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/EditorSeoAside.d.ts +7 -4
- package/dist/components/EditorSeoAside.d.ts.map +1 -1
- package/dist/components/EditorSeoAside.js +7 -16
- package/dist/components/EditorSeoAside.js.map +1 -1
- package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
- package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
- package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
- package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
- package/dist/components/seo/SeoAccordion.d.ts +16 -0
- package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
- package/dist/components/seo/SeoAccordion.js +21 -0
- package/dist/components/seo/SeoAccordion.js.map +1 -0
- package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
- package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
- package/dist/components/seo/SeoAnalysisList.js +32 -0
- package/dist/components/seo/SeoAnalysisList.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +15 -4
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +131 -31
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
- package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
- package/dist/components/seo/SeoInsightsMetrics.js +16 -0
- package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
- package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
- package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
- package/dist/components/seo/SeoLinkingSection.js +44 -0
- package/dist/components/seo/SeoLinkingSection.js.map +1 -0
- package/dist/lib/editor-seo-content.d.ts +16 -0
- package/dist/lib/editor-seo-content.d.ts.map +1 -0
- package/dist/lib/editor-seo-content.js +139 -0
- package/dist/lib/editor-seo-content.js.map +1 -0
- package/dist/lib/page-editor-service.d.ts +2 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +4 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +3 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +4 -0
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +2 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +5 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +9 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +38 -6
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +47 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +2 -13
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +17 -79
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +48 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +7 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/post-editor-service.test.ts +2 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +24 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +5 -9
- package/src/__tests__/views/seo-content-pane.render.test.tsx +2 -0
- package/src/components/EditorSeoAside.tsx +27 -108
- package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
- package/src/components/seo/SeoAccordion.tsx +76 -0
- package/src/components/seo/SeoAnalysisList.tsx +113 -0
- package/src/components/seo/SeoEditorPane.tsx +497 -244
- package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
- package/src/components/seo/SeoLinkingSection.tsx +151 -0
- package/src/lib/editor-seo-content.ts +144 -0
- package/src/lib/page-editor-service.ts +8 -0
- package/src/lib/post-editor-service.ts +8 -0
- package/src/lib/seo-service.ts +7 -0
- package/src/views/page-editor/EditorTopBar.tsx +76 -4
- package/src/views/page-editor/PageSectionEditor.tsx +70 -0
- package/src/views/page-editor/PageSettingsModal.tsx +1 -45
- package/src/views/post-editor/PostFieldsModal.tsx +19 -138
- package/src/views/post-editor/PostSectionEditor.tsx +72 -0
- package/src/views/seo/ContentTab.tsx +11 -5
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
4
4
|
import { AlertTriangle, Blocks, Loader2, Lock } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
7
|
+
import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
|
|
7
8
|
import { EditorSeoAside } from '../../components/EditorSeoAside.js'
|
|
8
9
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
9
10
|
import { VersionHistory } from '../../components/VersionHistory.js'
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
missingPageFields,
|
|
17
18
|
moveSection,
|
|
18
19
|
publishPage,
|
|
20
|
+
PAGES_COLLECTION,
|
|
19
21
|
removeSection,
|
|
20
22
|
reorderSections,
|
|
21
23
|
savePageDraft,
|
|
@@ -45,6 +47,7 @@ import { AddSectionModal } from './AddSectionModal.js'
|
|
|
45
47
|
import { PageSettingsModal } from './PageSettingsModal.js'
|
|
46
48
|
import { ValidationSummary } from './ValidationSummary.js'
|
|
47
49
|
import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
|
|
50
|
+
import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
|
|
48
51
|
|
|
49
52
|
interface PageSectionEditorProps {
|
|
50
53
|
/** undefined when creating a new page (`/pages/new`) */
|
|
@@ -102,6 +105,7 @@ export function PageSectionEditor({
|
|
|
102
105
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
103
106
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
|
|
104
107
|
const [confirmPublish, setConfirmPublish] = useState(false)
|
|
108
|
+
const [scheduleOpen, setScheduleOpen] = useState(false)
|
|
105
109
|
const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
|
|
106
110
|
const [validation, setValidation] = useState<ValidationResult | null>(null)
|
|
107
111
|
|
|
@@ -128,6 +132,17 @@ export function PageSectionEditor({
|
|
|
128
132
|
[page],
|
|
129
133
|
)
|
|
130
134
|
const publishReady = Boolean(page) && publishCheck.errors.length === 0
|
|
135
|
+
|
|
136
|
+
const liveSeoScore = useMemo(() => {
|
|
137
|
+
if (!page) return 0
|
|
138
|
+
return computeLiveSeoScore({
|
|
139
|
+
title: page.title,
|
|
140
|
+
slug: page.slug,
|
|
141
|
+
seoTitle: page.seoTitle,
|
|
142
|
+
seoDescription: page.seoDescription,
|
|
143
|
+
sections: page.sections,
|
|
144
|
+
})
|
|
145
|
+
}, [page])
|
|
131
146
|
const publishDisabledReason = publishCheck.errors[0]
|
|
132
147
|
|
|
133
148
|
// ─── Load ──────────────────────────────────────────────────────────
|
|
@@ -312,6 +327,42 @@ export function PageSectionEditor({
|
|
|
312
327
|
setConfirmPublish(true)
|
|
313
328
|
}, [page])
|
|
314
329
|
|
|
330
|
+
const requestSchedule = useCallback(async () => {
|
|
331
|
+
if (!page) return
|
|
332
|
+
if (!page.id) {
|
|
333
|
+
toast.error('Save the page before scheduling')
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
const result = validatePage(page, true)
|
|
337
|
+
setValidation(result.errors.length || result.warnings.length ? result : null)
|
|
338
|
+
if (result.errors.length > 0) {
|
|
339
|
+
toast.error('Resolve the listed issues before scheduling')
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
if (dirty) {
|
|
343
|
+
const ok = await handleSaveDraft()
|
|
344
|
+
if (!ok) return
|
|
345
|
+
}
|
|
346
|
+
setScheduleOpen(true)
|
|
347
|
+
}, [page, dirty, handleSaveDraft])
|
|
348
|
+
|
|
349
|
+
const handleScheduled = useCallback(
|
|
350
|
+
(next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
|
|
351
|
+
setPage((prev) =>
|
|
352
|
+
prev
|
|
353
|
+
? {
|
|
354
|
+
...prev,
|
|
355
|
+
status: next.status as EditorPage['status'],
|
|
356
|
+
scheduledAt: next.scheduledAt,
|
|
357
|
+
scheduledUnpublishAt: next.scheduledUnpublishAt,
|
|
358
|
+
}
|
|
359
|
+
: prev,
|
|
360
|
+
)
|
|
361
|
+
setValidation(null)
|
|
362
|
+
},
|
|
363
|
+
[],
|
|
364
|
+
)
|
|
365
|
+
|
|
315
366
|
const doPublish = useCallback(async () => {
|
|
316
367
|
if (!page) return
|
|
317
368
|
setPublishing(true)
|
|
@@ -518,8 +569,13 @@ export function PageSectionEditor({
|
|
|
518
569
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
519
570
|
onOpenSeo={handleToggleSeo}
|
|
520
571
|
seoOpen={seoOpen}
|
|
572
|
+
seoScore={liveSeoScore}
|
|
521
573
|
onSaveDraft={handleSaveDraft}
|
|
522
574
|
onPublish={requestPublish}
|
|
575
|
+
onSchedule={page.id ? requestSchedule : undefined}
|
|
576
|
+
scheduleReady={Boolean(page.id)}
|
|
577
|
+
scheduledAt={page.scheduledAt}
|
|
578
|
+
scheduledUnpublishAt={page.scheduledUnpublishAt}
|
|
523
579
|
structuralTypes={structuralTypes}
|
|
524
580
|
/>
|
|
525
581
|
|
|
@@ -622,8 +678,10 @@ export function PageSectionEditor({
|
|
|
622
678
|
entityType="page"
|
|
623
679
|
entityId={page.id}
|
|
624
680
|
title={page.title}
|
|
681
|
+
slug={page.slug}
|
|
625
682
|
seoTitle={page.seoTitle}
|
|
626
683
|
seoDescription={page.seoDescription}
|
|
684
|
+
sections={page.sections}
|
|
627
685
|
canEdit={effectiveCanEdit}
|
|
628
686
|
onClose={() => setSeoOpen(false)}
|
|
629
687
|
onDraftChange={handlePageSettings}
|
|
@@ -672,6 +730,18 @@ export function PageSectionEditor({
|
|
|
672
730
|
confirmLabel="Publish"
|
|
673
731
|
/>
|
|
674
732
|
|
|
733
|
+
{page.id && (
|
|
734
|
+
<SchedulePublishDialog
|
|
735
|
+
collectionSlug={PAGES_COLLECTION}
|
|
736
|
+
documentId={page.id}
|
|
737
|
+
scheduledAt={page.scheduledAt}
|
|
738
|
+
scheduledUnpublishAt={page.scheduledUnpublishAt}
|
|
739
|
+
open={scheduleOpen}
|
|
740
|
+
onClose={() => setScheduleOpen(false)}
|
|
741
|
+
onScheduled={handleScheduled}
|
|
742
|
+
/>
|
|
743
|
+
)}
|
|
744
|
+
|
|
675
745
|
<ConfirmDialog
|
|
676
746
|
open={leaveTarget !== null}
|
|
677
747
|
onClose={() => setLeaveTarget(null)}
|
|
@@ -4,15 +4,9 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
4
4
|
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
5
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
6
6
|
import { Button } from '../../components/ui/Button.js'
|
|
7
|
-
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
8
7
|
import { cmsApi } from '../../lib/api.js'
|
|
9
8
|
import type { EditorPage } from '../../lib/page-editor-service.js'
|
|
10
9
|
|
|
11
|
-
const SEO_TITLE_MIN = 30
|
|
12
|
-
const SEO_TITLE_MAX = 60
|
|
13
|
-
const SEO_DESC_MIN = 120
|
|
14
|
-
const SEO_DESC_MAX = 160
|
|
15
|
-
|
|
16
10
|
interface PageSettingsModalProps {
|
|
17
11
|
open: boolean
|
|
18
12
|
page: EditorPage
|
|
@@ -32,7 +26,7 @@ interface ParentOption {
|
|
|
32
26
|
title: string
|
|
33
27
|
}
|
|
34
28
|
|
|
35
|
-
/** Edits page-level fields: title, slug, path, parent page
|
|
29
|
+
/** Edits page-level fields: title, slug, path, and parent page. SEO lives in the SEO tab. */
|
|
36
30
|
export function PageSettingsModal({
|
|
37
31
|
open,
|
|
38
32
|
page,
|
|
@@ -44,8 +38,6 @@ export function PageSettingsModal({
|
|
|
44
38
|
const [slug, setSlug] = useState(page.slug)
|
|
45
39
|
const [path, setPath] = useState(page.path)
|
|
46
40
|
const [parentPageId, setParentPageId] = useState(page.parentPageId ?? '')
|
|
47
|
-
const [seoTitle, setSeoTitle] = useState(page.seoTitle)
|
|
48
|
-
const [seoDescription, setSeoDescription] = useState(page.seoDescription)
|
|
49
41
|
// Auto-derive slug/path from title until the user edits slug manually.
|
|
50
42
|
const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
|
|
51
43
|
const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
|
|
@@ -59,8 +51,6 @@ export function PageSettingsModal({
|
|
|
59
51
|
setSlug(page.slug)
|
|
60
52
|
setPath(page.path)
|
|
61
53
|
setParentPageId(page.parentPageId ?? '')
|
|
62
|
-
setSeoTitle(page.seoTitle)
|
|
63
|
-
setSeoDescription(page.seoDescription)
|
|
64
54
|
setSlugTouched(Boolean(page.slug))
|
|
65
55
|
}, [open, page])
|
|
66
56
|
|
|
@@ -110,8 +100,6 @@ export function PageSettingsModal({
|
|
|
110
100
|
slug,
|
|
111
101
|
path: path || (slug ? `/${slug}` : '/'),
|
|
112
102
|
parentPageId: parentPageId || null,
|
|
113
|
-
seoTitle,
|
|
114
|
-
seoDescription,
|
|
115
103
|
})
|
|
116
104
|
onClose()
|
|
117
105
|
}
|
|
@@ -231,38 +219,6 @@ export function PageSettingsModal({
|
|
|
231
219
|
Nests this page under a parent in the Pages tree view.
|
|
232
220
|
</p>
|
|
233
221
|
</div>
|
|
234
|
-
<div>
|
|
235
|
-
<div className="mb-1 flex items-center justify-between">
|
|
236
|
-
<label className={`${LABEL} mb-0`} htmlFor="ps-seo-title">
|
|
237
|
-
SEO title
|
|
238
|
-
</label>
|
|
239
|
-
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
240
|
-
</div>
|
|
241
|
-
<input
|
|
242
|
-
id="ps-seo-title"
|
|
243
|
-
className={INPUT}
|
|
244
|
-
value={seoTitle}
|
|
245
|
-
disabled={!canEdit}
|
|
246
|
-
placeholder={title || 'Defaults to the page title'}
|
|
247
|
-
onChange={(e) => setSeoTitle(e.target.value)}
|
|
248
|
-
/>
|
|
249
|
-
</div>
|
|
250
|
-
<div>
|
|
251
|
-
<div className="mb-1 flex items-center justify-between">
|
|
252
|
-
<label className={`${LABEL} mb-0`} htmlFor="ps-seo-desc">
|
|
253
|
-
SEO description
|
|
254
|
-
</label>
|
|
255
|
-
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
256
|
-
</div>
|
|
257
|
-
<textarea
|
|
258
|
-
id="ps-seo-desc"
|
|
259
|
-
className={`${INPUT} min-h-[64px] resize-y`}
|
|
260
|
-
value={seoDescription}
|
|
261
|
-
disabled={!canEdit}
|
|
262
|
-
placeholder="Summarize the page for search results (120–160 characters)"
|
|
263
|
-
onChange={(e) => setSeoDescription(e.target.value)}
|
|
264
|
-
/>
|
|
265
|
-
</div>
|
|
266
222
|
</form>
|
|
267
223
|
</Modal>
|
|
268
224
|
)
|
|
@@ -9,16 +9,9 @@ import { Button } from '../../components/ui/Button.js'
|
|
|
9
9
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
10
10
|
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
11
11
|
import { TagInput } from '../../components/TagInput.js'
|
|
12
|
-
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
13
12
|
import { coauthorText } from '../../lib/coauthor-client.js'
|
|
14
|
-
import { generateSeoField } from '../../lib/seo-service.js'
|
|
15
13
|
import type { EditorPost } from '../../lib/post-editor-service.js'
|
|
16
14
|
|
|
17
|
-
const SEO_TITLE_MIN = 30
|
|
18
|
-
const SEO_TITLE_MAX = 60
|
|
19
|
-
const SEO_DESC_MIN = 120
|
|
20
|
-
const SEO_DESC_MAX = 160
|
|
21
|
-
|
|
22
15
|
/**
|
|
23
16
|
* The slice of a collection's field config the modal reads to pick controls:
|
|
24
17
|
* a `select` category renders a dropdown (structured vocabulary), an `array`
|
|
@@ -47,8 +40,6 @@ const INPUT =
|
|
|
47
40
|
|
|
48
41
|
const slugify = normalizeSlug
|
|
49
42
|
|
|
50
|
-
type AiTarget = 'excerpt' | 'seoTitle' | 'seoDescription'
|
|
51
|
-
|
|
52
43
|
type FormSnapshot = {
|
|
53
44
|
title: string
|
|
54
45
|
slug: string
|
|
@@ -56,8 +47,6 @@ type FormSnapshot = {
|
|
|
56
47
|
featuredImage: string
|
|
57
48
|
category: string
|
|
58
49
|
tags: string
|
|
59
|
-
seoTitle: string
|
|
60
|
-
seoDescription: string
|
|
61
50
|
}
|
|
62
51
|
|
|
63
52
|
function snapshotFromPost(post: EditorPost): FormSnapshot {
|
|
@@ -68,8 +57,6 @@ function snapshotFromPost(post: EditorPost): FormSnapshot {
|
|
|
68
57
|
featuredImage: post.featuredImage,
|
|
69
58
|
category: post.category,
|
|
70
59
|
tags: [...post.tags].sort().join('\0'),
|
|
71
|
-
seoTitle: post.seoTitle,
|
|
72
|
-
seoDescription: post.seoDescription,
|
|
73
60
|
}
|
|
74
61
|
}
|
|
75
62
|
|
|
@@ -80,8 +67,6 @@ function snapshotFromFields(input: {
|
|
|
80
67
|
featuredImage: string
|
|
81
68
|
category: string
|
|
82
69
|
tags: string[]
|
|
83
|
-
seoTitle: string
|
|
84
|
-
seoDescription: string
|
|
85
70
|
}): FormSnapshot {
|
|
86
71
|
return {
|
|
87
72
|
...input,
|
|
@@ -132,7 +117,7 @@ function AiFieldButton({
|
|
|
132
117
|
)
|
|
133
118
|
}
|
|
134
119
|
|
|
135
|
-
/** Edits post metadata: title, slug, excerpt, featured image, category, tags
|
|
120
|
+
/** Edits post metadata: title, slug, excerpt, featured image, category, and tags. SEO lives in the SEO tab. */
|
|
136
121
|
export function PostFieldsModal({
|
|
137
122
|
open,
|
|
138
123
|
post,
|
|
@@ -147,11 +132,9 @@ export function PostFieldsModal({
|
|
|
147
132
|
const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
|
|
148
133
|
const [category, setCategory] = useState(post.category)
|
|
149
134
|
const [tags, setTags] = useState<string[]>(post.tags)
|
|
150
|
-
const [seoTitle, setSeoTitle] = useState(post.seoTitle)
|
|
151
|
-
const [seoDescription, setSeoDescription] = useState(post.seoDescription)
|
|
152
135
|
const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
|
|
153
136
|
const [mediaOpen, setMediaOpen] = useState(false)
|
|
154
|
-
const [aiBusy, setAiBusy] = useState
|
|
137
|
+
const [aiBusy, setAiBusy] = useState(false)
|
|
155
138
|
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
|
156
139
|
const baselineRef = useRef<FormSnapshot | null>(null)
|
|
157
140
|
// Land the user in the Title field (name-it-first flow for new posts).
|
|
@@ -177,10 +160,8 @@ export function PostFieldsModal({
|
|
|
177
160
|
setFeaturedImage(post.featuredImage)
|
|
178
161
|
setCategory(post.category)
|
|
179
162
|
setTags(post.tags)
|
|
180
|
-
setSeoTitle(post.seoTitle)
|
|
181
|
-
setSeoDescription(post.seoDescription)
|
|
182
163
|
setSlugTouched(Boolean(post.slug))
|
|
183
|
-
setAiBusy(
|
|
164
|
+
setAiBusy(false)
|
|
184
165
|
baselineRef.current = snapshotFromPost(post)
|
|
185
166
|
}, [open, post])
|
|
186
167
|
|
|
@@ -193,11 +174,9 @@ export function PostFieldsModal({
|
|
|
193
174
|
featuredImage,
|
|
194
175
|
category,
|
|
195
176
|
tags,
|
|
196
|
-
seoTitle,
|
|
197
|
-
seoDescription,
|
|
198
177
|
})
|
|
199
178
|
return JSON.stringify(current) !== JSON.stringify(baselineRef.current)
|
|
200
|
-
}, [open, title, slug, excerpt, featuredImage, category, tags
|
|
179
|
+
}, [open, title, slug, excerpt, featuredImage, category, tags])
|
|
201
180
|
|
|
202
181
|
function requestDismiss() {
|
|
203
182
|
if (isDirty) {
|
|
@@ -214,7 +193,7 @@ export function PostFieldsModal({
|
|
|
214
193
|
|
|
215
194
|
const slugError = validateSlug(slug)
|
|
216
195
|
|
|
217
|
-
async function
|
|
196
|
+
async function handleAiGenerateExcerpt() {
|
|
218
197
|
const context = buildContentContext(title, post.body, excerpt)
|
|
219
198
|
if (!context.trim()) {
|
|
220
199
|
toast.error(
|
|
@@ -223,77 +202,29 @@ export function PostFieldsModal({
|
|
|
223
202
|
return
|
|
224
203
|
}
|
|
225
204
|
|
|
226
|
-
setAiBusy(
|
|
205
|
+
setAiBusy(true)
|
|
227
206
|
try {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
toast.error(outcome.error)
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
setExcerpt(outcome.result.text.trim())
|
|
239
|
-
toast.success('Excerpt generated — review before saving.')
|
|
207
|
+
const outcome = await coauthorText('compress', context, {
|
|
208
|
+
instructions:
|
|
209
|
+
'Write a 1–2 sentence excerpt for blog listings and cards. Return only the excerpt text.',
|
|
210
|
+
targetLength: 45,
|
|
211
|
+
})
|
|
212
|
+
if (!outcome.ok) {
|
|
213
|
+
toast.error(outcome.error)
|
|
240
214
|
return
|
|
241
215
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const field = target === 'seoTitle' ? 'metaTitle' : 'metaDescription'
|
|
245
|
-
const result = await generateSeoField(field, {
|
|
246
|
-
entityType: post.postType,
|
|
247
|
-
entityId: post.id,
|
|
248
|
-
})
|
|
249
|
-
if (result.unavailable) {
|
|
250
|
-
toast.error('AI is not configured. Add an AI provider to enable suggestions.')
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
if (result.text) {
|
|
254
|
-
if (target === 'seoTitle') setSeoTitle(result.text)
|
|
255
|
-
else setSeoDescription(result.text)
|
|
256
|
-
toast.success('AI suggestion applied — review before saving.')
|
|
257
|
-
} else {
|
|
258
|
-
toast.error('No suggestion was returned.')
|
|
259
|
-
}
|
|
260
|
-
return
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (target === 'seoTitle') {
|
|
264
|
-
const outcome = await coauthorText('rewrite', context, {
|
|
265
|
-
style: 'seo-optimized',
|
|
266
|
-
instructions:
|
|
267
|
-
'Write a single SEO page title under 60 characters. Return only the title — no quotes.',
|
|
268
|
-
})
|
|
269
|
-
if (!outcome.ok) {
|
|
270
|
-
toast.error(outcome.error)
|
|
271
|
-
return
|
|
272
|
-
}
|
|
273
|
-
setSeoTitle(outcome.result.text.trim().slice(0, SEO_TITLE_MAX))
|
|
274
|
-
} else {
|
|
275
|
-
const outcome = await coauthorText('compress', context, {
|
|
276
|
-
instructions:
|
|
277
|
-
'Write a compelling SEO meta description of 120–160 characters for search results. Return only the description.',
|
|
278
|
-
targetLength: 155,
|
|
279
|
-
})
|
|
280
|
-
if (!outcome.ok) {
|
|
281
|
-
toast.error(outcome.error)
|
|
282
|
-
return
|
|
283
|
-
}
|
|
284
|
-
setSeoDescription(outcome.result.text.trim().slice(0, SEO_DESC_MAX))
|
|
285
|
-
}
|
|
286
|
-
toast.success('AI suggestion applied — review before saving.')
|
|
216
|
+
setExcerpt(outcome.result.text.trim())
|
|
217
|
+
toast.success('Excerpt generated — review before saving.')
|
|
287
218
|
} catch {
|
|
288
219
|
toast.error('AI request failed.')
|
|
289
220
|
} finally {
|
|
290
|
-
setAiBusy(
|
|
221
|
+
setAiBusy(false)
|
|
291
222
|
}
|
|
292
223
|
}
|
|
293
224
|
|
|
294
225
|
function handleSave() {
|
|
295
226
|
if (slugError) return
|
|
296
|
-
onSave({ title, slug, excerpt, featuredImage, category, tags
|
|
227
|
+
onSave({ title, slug, excerpt, featuredImage, category, tags })
|
|
297
228
|
onClose()
|
|
298
229
|
}
|
|
299
230
|
|
|
@@ -421,9 +352,9 @@ export function PostFieldsModal({
|
|
|
421
352
|
</label>
|
|
422
353
|
<AiFieldButton
|
|
423
354
|
label="Generate excerpt from content"
|
|
424
|
-
busy={aiBusy
|
|
355
|
+
busy={aiBusy}
|
|
425
356
|
disabled={!canEdit}
|
|
426
|
-
onClick={() => void
|
|
357
|
+
onClick={() => void handleAiGenerateExcerpt()}
|
|
427
358
|
/>
|
|
428
359
|
</div>
|
|
429
360
|
<textarea
|
|
@@ -464,56 +395,6 @@ export function PostFieldsModal({
|
|
|
464
395
|
</div>
|
|
465
396
|
)}
|
|
466
397
|
</div>
|
|
467
|
-
|
|
468
|
-
<div>
|
|
469
|
-
<div className="mb-1 flex items-center justify-between gap-2">
|
|
470
|
-
<div className="flex min-w-0 items-center gap-2">
|
|
471
|
-
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-title">
|
|
472
|
-
SEO title
|
|
473
|
-
</label>
|
|
474
|
-
<AiFieldButton
|
|
475
|
-
label="Generate SEO title from content"
|
|
476
|
-
busy={aiBusy === 'seoTitle'}
|
|
477
|
-
disabled={!canEdit}
|
|
478
|
-
onClick={() => void handleAiGenerate('seoTitle')}
|
|
479
|
-
/>
|
|
480
|
-
</div>
|
|
481
|
-
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
482
|
-
</div>
|
|
483
|
-
<input
|
|
484
|
-
id="pf-seo-title"
|
|
485
|
-
className={INPUT}
|
|
486
|
-
value={seoTitle}
|
|
487
|
-
disabled={!canEdit}
|
|
488
|
-
placeholder={title || 'Defaults to the post title'}
|
|
489
|
-
onChange={(e) => setSeoTitle(e.target.value)}
|
|
490
|
-
/>
|
|
491
|
-
</div>
|
|
492
|
-
|
|
493
|
-
<div>
|
|
494
|
-
<div className="mb-1 flex items-center justify-between gap-2">
|
|
495
|
-
<div className="flex min-w-0 items-center gap-2">
|
|
496
|
-
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-desc">
|
|
497
|
-
SEO description
|
|
498
|
-
</label>
|
|
499
|
-
<AiFieldButton
|
|
500
|
-
label="Generate SEO description from content"
|
|
501
|
-
busy={aiBusy === 'seoDescription'}
|
|
502
|
-
disabled={!canEdit}
|
|
503
|
-
onClick={() => void handleAiGenerate('seoDescription')}
|
|
504
|
-
/>
|
|
505
|
-
</div>
|
|
506
|
-
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
507
|
-
</div>
|
|
508
|
-
<textarea
|
|
509
|
-
id="pf-seo-desc"
|
|
510
|
-
className={`${INPUT} min-h-[72px] resize-y`}
|
|
511
|
-
value={seoDescription}
|
|
512
|
-
disabled={!canEdit}
|
|
513
|
-
placeholder={excerpt || 'Summarize the post for search results (120–160 characters)'}
|
|
514
|
-
onChange={(e) => setSeoDescription(e.target.value)}
|
|
515
|
-
/>
|
|
516
|
-
</div>
|
|
517
398
|
</div>
|
|
518
399
|
</Modal>
|
|
519
400
|
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
4
4
|
import { AlertTriangle, Loader2, Lock } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
7
|
+
import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
|
|
7
8
|
import { EditorSeoAside } from '../../components/EditorSeoAside.js'
|
|
8
9
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
9
10
|
import { VersionHistory } from '../../components/VersionHistory.js'
|
|
@@ -50,6 +51,7 @@ import { SectionInspector } from '../page-editor/SectionInspector.js'
|
|
|
50
51
|
import { AddSectionModal } from '../page-editor/AddSectionModal.js'
|
|
51
52
|
import { ValidationSummary } from '../page-editor/ValidationSummary.js'
|
|
52
53
|
import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
|
|
54
|
+
import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
|
|
53
55
|
import { PostEditorCanvas } from './PostEditorCanvas.js'
|
|
54
56
|
import { PostFieldsModal, type PostFieldConfig } from './PostFieldsModal.js'
|
|
55
57
|
|
|
@@ -127,6 +129,7 @@ export function PostSectionEditor({
|
|
|
127
129
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
128
130
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
|
|
129
131
|
const [confirmPublish, setConfirmPublish] = useState(false)
|
|
132
|
+
const [scheduleOpen, setScheduleOpen] = useState(false)
|
|
130
133
|
const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
|
|
131
134
|
const [validation, setValidation] = useState<ValidationResult | null>(null)
|
|
132
135
|
|
|
@@ -158,6 +161,18 @@ export function PostSectionEditor({
|
|
|
158
161
|
[post],
|
|
159
162
|
)
|
|
160
163
|
const publishReady = Boolean(post) && publishCheck.errors.length === 0
|
|
164
|
+
|
|
165
|
+
const liveSeoScore = useMemo(() => {
|
|
166
|
+
if (!post) return 0
|
|
167
|
+
return computeLiveSeoScore({
|
|
168
|
+
title: post.title,
|
|
169
|
+
slug: post.slug,
|
|
170
|
+
seoTitle: post.seoTitle,
|
|
171
|
+
seoDescription: post.seoDescription,
|
|
172
|
+
sections: post.sections,
|
|
173
|
+
bodyHtml: post.body,
|
|
174
|
+
})
|
|
175
|
+
}, [post])
|
|
161
176
|
const publishDisabledReason = publishCheck.errors[0]
|
|
162
177
|
|
|
163
178
|
// ─── Load ──────────────────────────────────────────────────────────
|
|
@@ -362,6 +377,43 @@ export function PostSectionEditor({
|
|
|
362
377
|
setConfirmPublish(true)
|
|
363
378
|
}, [post])
|
|
364
379
|
|
|
380
|
+
const requestSchedule = useCallback(async () => {
|
|
381
|
+
if (!post) return
|
|
382
|
+
if (!post.id) {
|
|
383
|
+
toast.error('Save the post before scheduling')
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
const result = validatePost(post, true)
|
|
387
|
+
setValidation(result.errors.length || result.warnings.length ? result : null)
|
|
388
|
+
if (result.errors.length > 0) {
|
|
389
|
+
toast.error('Resolve the listed issues before scheduling')
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
if (dirty) {
|
|
393
|
+
const ok = await handleSaveDraft()
|
|
394
|
+
if (!ok) return
|
|
395
|
+
}
|
|
396
|
+
setScheduleOpen(true)
|
|
397
|
+
}, [post, dirty, handleSaveDraft])
|
|
398
|
+
|
|
399
|
+
const handleScheduled = useCallback(
|
|
400
|
+
(next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
|
|
401
|
+
setPost((prev) =>
|
|
402
|
+
prev
|
|
403
|
+
? {
|
|
404
|
+
...prev,
|
|
405
|
+
status: next.status as EditorPost['status'],
|
|
406
|
+
scheduledAt: next.scheduledAt,
|
|
407
|
+
scheduledUnpublishAt: next.scheduledUnpublishAt,
|
|
408
|
+
publishDate: next.scheduledAt ?? prev.publishDate,
|
|
409
|
+
}
|
|
410
|
+
: prev,
|
|
411
|
+
)
|
|
412
|
+
setValidation(null)
|
|
413
|
+
},
|
|
414
|
+
[],
|
|
415
|
+
)
|
|
416
|
+
|
|
365
417
|
const doPublish = useCallback(async () => {
|
|
366
418
|
if (!post) return
|
|
367
419
|
setPublishing(true)
|
|
@@ -560,8 +612,13 @@ export function PostSectionEditor({
|
|
|
560
612
|
onOpenSettings={() => setFieldsOpen(true)}
|
|
561
613
|
onOpenSeo={handleToggleSeo}
|
|
562
614
|
seoOpen={seoOpen}
|
|
615
|
+
seoScore={liveSeoScore}
|
|
563
616
|
onSaveDraft={handleSaveDraft}
|
|
564
617
|
onPublish={requestPublish}
|
|
618
|
+
onSchedule={post.id ? requestSchedule : undefined}
|
|
619
|
+
scheduleReady={Boolean(post.id)}
|
|
620
|
+
scheduledAt={post.scheduledAt}
|
|
621
|
+
scheduledUnpublishAt={post.scheduledUnpublishAt}
|
|
565
622
|
structuralTypes={structuralTypes}
|
|
566
623
|
/>
|
|
567
624
|
|
|
@@ -645,8 +702,11 @@ export function PostSectionEditor({
|
|
|
645
702
|
entityType="post"
|
|
646
703
|
entityId={post.id}
|
|
647
704
|
title={post.title}
|
|
705
|
+
slug={post.slug}
|
|
648
706
|
seoTitle={post.seoTitle}
|
|
649
707
|
seoDescription={post.seoDescription}
|
|
708
|
+
sections={post.sections}
|
|
709
|
+
bodyHtml={post.body}
|
|
650
710
|
canEdit={canEdit}
|
|
651
711
|
onClose={() => setSeoOpen(false)}
|
|
652
712
|
onDraftChange={handleFields}
|
|
@@ -701,6 +761,18 @@ export function PostSectionEditor({
|
|
|
701
761
|
confirmLabel="Publish"
|
|
702
762
|
/>
|
|
703
763
|
|
|
764
|
+
{post.id && (
|
|
765
|
+
<SchedulePublishDialog
|
|
766
|
+
collectionSlug={postType}
|
|
767
|
+
documentId={post.id}
|
|
768
|
+
scheduledAt={post.scheduledAt}
|
|
769
|
+
scheduledUnpublishAt={post.scheduledUnpublishAt}
|
|
770
|
+
open={scheduleOpen}
|
|
771
|
+
onClose={() => setScheduleOpen(false)}
|
|
772
|
+
onScheduled={handleScheduled}
|
|
773
|
+
/>
|
|
774
|
+
)}
|
|
775
|
+
|
|
704
776
|
<ConfirmDialog
|
|
705
777
|
open={leaveTarget !== null}
|
|
706
778
|
onClose={() => setLeaveTarget(null)}
|
|
@@ -34,7 +34,12 @@ const FILTERS: { id: ContentSeoFilter; label: string }[] = [
|
|
|
34
34
|
const inputCls =
|
|
35
35
|
'w-full rounded-md border border-[var(--bdr)] bg-input-background py-2 pl-9 pr-3 text-base text-[var(--txt)] placeholder:text-[var(--sub)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]'
|
|
36
36
|
|
|
37
|
-
type
|
|
37
|
+
type SeoEntityType = 'page' | 'post'
|
|
38
|
+
type Selection = { type: SeoEntityType; id: string } | null
|
|
39
|
+
|
|
40
|
+
function parseEntityType(value: string | null | undefined): SeoEntityType | null {
|
|
41
|
+
return value === 'page' || value === 'post' ? value : null
|
|
42
|
+
}
|
|
38
43
|
|
|
39
44
|
export function ContentTab({
|
|
40
45
|
initialEntityType,
|
|
@@ -46,16 +51,17 @@ export function ContentTab({
|
|
|
46
51
|
const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
|
|
47
52
|
const [search, setSearch] = useState('')
|
|
48
53
|
const [filter, setFilter] = useState<ContentSeoFilter>('all')
|
|
49
|
-
const [selection, setSelection] = useState<Selection>(
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
const [selection, setSelection] = useState<Selection>(() => {
|
|
55
|
+
const type = parseEntityType(initialEntityType)
|
|
56
|
+
return type && initialEntityId ? { type, id: initialEntityId } : null
|
|
57
|
+
})
|
|
52
58
|
|
|
53
59
|
const rows = useMemo(
|
|
54
60
|
() => applyContentSeoQuery(data ?? [], { search, filter }),
|
|
55
61
|
[data, search, filter],
|
|
56
62
|
)
|
|
57
63
|
|
|
58
|
-
const toggleRow = (type:
|
|
64
|
+
const toggleRow = (type: SeoEntityType, id: string) => {
|
|
59
65
|
setSelection((prev) => (prev?.type === type && prev.id === id ? null : { type, id }))
|
|
60
66
|
}
|
|
61
67
|
|