@prenta/admin 1.4.0 → 1.6.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 +22 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +5 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/request-changes-dialog.render.test.d.ts +2 -0
- package/dist/__tests__/components/request-changes-dialog.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/request-changes-dialog.render.test.js +12 -0
- package/dist/__tests__/components/request-changes-dialog.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.js +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/lib/page-editor-service-api.test.js +28 -15
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service-api.test.d.ts +2 -0
- package/dist/__tests__/lib/post-editor-service-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-editor-service-api.test.js +67 -0
- package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -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__/lib/workflow-chrome.test.d.ts +2 -0
- package/dist/__tests__/lib/workflow-chrome.test.d.ts.map +1 -0
- package/dist/__tests__/lib/workflow-chrome.test.js +108 -0
- package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -0
- package/dist/__tests__/router/admin-routes.test.js +1 -0
- package/dist/__tests__/router/admin-routes.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +40 -33
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.js +8 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/page-tab.render.test.js +2 -0
- package/dist/__tests__/views/page-tab.render.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.js +2 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +2 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-tab.render.test.js +2 -0
- package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
- package/dist/__tests__/views/trash.render.test.d.ts +2 -0
- package/dist/__tests__/views/trash.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/trash.render.test.js +55 -0
- package/dist/__tests__/views/trash.render.test.js.map +1 -0
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +1 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/RequestChangesDialog.d.ts +6 -0
- package/dist/components/RequestChangesDialog.d.ts.map +1 -0
- package/dist/components/RequestChangesDialog.js +15 -0
- package/dist/components/RequestChangesDialog.js.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -2
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +6 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +16 -12
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +16 -11
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/workflow-chrome.d.ts +22 -0
- package/dist/lib/workflow-chrome.d.ts.map +1 -0
- package/dist/lib/workflow-chrome.js +80 -0
- package/dist/lib/workflow-chrome.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/router/admin-routes.d.ts.map +1 -1
- package/dist/router/admin-routes.js +2 -0
- package/dist/router/admin-routes.js.map +1 -1
- package/dist/views/ApiKeys.js +1 -5
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +1 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Trash.d.ts +18 -0
- package/dist/views/Trash.d.ts.map +1 -0
- package/dist/views/Trash.js +104 -0
- package/dist/views/Trash.js.map +1 -0
- package/dist/views/page-editor/EditorTopBar.d.ts +10 -5
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +38 -28
- 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 +83 -62
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +78 -62
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/OverviewTab.d.ts +1 -2
- package/dist/views/seo/OverviewTab.d.ts.map +1 -1
- package/dist/views/seo/OverviewTab.js +3 -3
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/package.json +4 -4
- package/src/AdminRoot.tsx +6 -0
- package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
- package/src/__tests__/layout/sidebar-posts.test.ts +1 -0
- package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
- package/src/__tests__/lib/post-editor-service-api.test.ts +73 -0
- package/src/__tests__/lib/post-editor-service.test.ts +2 -0
- package/src/__tests__/lib/workflow-chrome.test.ts +125 -0
- package/src/__tests__/router/admin-routes.test.ts +1 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -45
- package/src/__tests__/views/page-section-editor.test.tsx +18 -0
- package/src/__tests__/views/page-tab.render.test.tsx +2 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +2 -0
- package/src/__tests__/views/post-section-editor.test.tsx +2 -0
- package/src/__tests__/views/post-tab.render.test.tsx +2 -0
- package/src/__tests__/views/trash.render.test.tsx +61 -0
- package/src/components/Breadcrumbs.tsx +1 -0
- package/src/components/RequestChangesDialog.tsx +56 -0
- package/src/layout/Sidebar.tsx +3 -0
- package/src/lib/page-editor-service.ts +26 -11
- package/src/lib/post-editor-service.ts +26 -10
- package/src/lib/workflow-chrome.ts +105 -0
- package/src/router/admin-routes.ts +2 -0
- package/src/views/ApiKeys.tsx +0 -13
- package/src/views/SEO.tsx +0 -5
- package/src/views/Trash.tsx +207 -0
- package/src/views/page-editor/EditorTopBar.tsx +110 -48
- package/src/views/page-editor/PageSectionEditor.tsx +121 -92
- package/src/views/post-editor/PostSectionEditor.tsx +111 -88
- package/src/views/seo/OverviewTab.tsx +1 -11
|
@@ -22,16 +22,17 @@ import {
|
|
|
22
22
|
emptyPage,
|
|
23
23
|
fetchPageForEditor,
|
|
24
24
|
missingPageFields,
|
|
25
|
-
publishPage,
|
|
26
25
|
PAGES_COLLECTION,
|
|
27
26
|
savePageDraft,
|
|
28
27
|
toggleSectionVisibility,
|
|
28
|
+
transitionPageWorkflow,
|
|
29
29
|
updateSectionContent,
|
|
30
30
|
updateSectionMeta,
|
|
31
31
|
validatePage,
|
|
32
32
|
type EditorPage,
|
|
33
33
|
type ValidationResult,
|
|
34
34
|
} from '../../lib/page-editor-service.js'
|
|
35
|
+
import { resolveWorkflowChrome, type WorkflowStage } from '../../lib/workflow-chrome.js'
|
|
35
36
|
import { SharePreviewLinkDialog } from '../../components/SharePreviewLinkDialog.js'
|
|
36
37
|
import { openPublicSiteTab } from '../../lib/open-public-site-tab.js'
|
|
37
38
|
import type { ViewSiteIntent } from '../../lib/view-site-chrome.js'
|
|
@@ -210,13 +211,13 @@ export function PageSectionEditor({
|
|
|
210
211
|
/** Index into `versions`; 0 is the newest, which is the current content. */
|
|
211
212
|
const [versionIndex, setVersionIndex] = useState(0)
|
|
212
213
|
const [restoring, setRestoring] = useState(false)
|
|
213
|
-
const [confirmPublish, setConfirmPublish] = useState(false)
|
|
214
214
|
const [scheduleOpen, setScheduleOpen] = useState(false)
|
|
215
215
|
const [shareOpen, setShareOpen] = useState(false)
|
|
216
216
|
const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
|
|
217
217
|
const [validation, setValidation] = useState<ValidationResult | null>(null)
|
|
218
218
|
|
|
219
|
-
const { canEdit,
|
|
219
|
+
const { canEdit, canCreateRedirect } = useMemo(() => deriveCan(session), [session])
|
|
220
|
+
const sessionRole = String(session?.user?.role ?? '')
|
|
220
221
|
const customRenderers = useAdminSectionRenderers()
|
|
221
222
|
const {
|
|
222
223
|
siteUrl: publicSiteUrl,
|
|
@@ -535,43 +536,115 @@ export function PageSectionEditor({
|
|
|
535
536
|
[directives, reloadAudit],
|
|
536
537
|
)
|
|
537
538
|
|
|
538
|
-
const handleSaveDraft = useCallback(
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
539
|
+
const handleSaveDraft = useCallback(
|
|
540
|
+
async (options?: { navigate?: boolean }): Promise<EditorPage | false> => {
|
|
541
|
+
if (!page) return false
|
|
542
|
+
const result = validatePage(page, false)
|
|
543
|
+
if (result.errors.length > 0) {
|
|
544
|
+
setValidation(result)
|
|
545
|
+
toast.error(result.errors[0]!)
|
|
546
|
+
setSettingsOpen(true)
|
|
547
|
+
return false
|
|
548
|
+
}
|
|
549
|
+
setSaveState('saving')
|
|
550
|
+
try {
|
|
551
|
+
const saved = await savePageDraft(page)
|
|
552
|
+
if (saved.id) await saveDirectives(saved.id)
|
|
553
|
+
acceptPage(saved)
|
|
554
|
+
setDirty(false)
|
|
555
|
+
setSaveState('saved')
|
|
556
|
+
setValidation(null)
|
|
557
|
+
toast.success('Draft saved')
|
|
558
|
+
if ((options?.navigate ?? true) && !documentId && saved.id) {
|
|
559
|
+
onNavigate(`/pages/${saved.id}/edit`)
|
|
560
|
+
}
|
|
561
|
+
return saved
|
|
562
|
+
} catch (err) {
|
|
563
|
+
setSaveState('error')
|
|
564
|
+
toast.error(err instanceof Error ? err.message : 'Failed to save draft')
|
|
565
|
+
return false
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
[page, documentId, onNavigate, saveDirectives, acceptPage],
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
const runWorkflow = useCallback(
|
|
572
|
+
async (stage: WorkflowStage, note?: string, toastOk?: string) => {
|
|
573
|
+
if (!page) return
|
|
574
|
+
let current = page
|
|
575
|
+
if (dirty || !current.id) {
|
|
576
|
+
const saved = await handleSaveDraft({ navigate: false })
|
|
577
|
+
if (!saved) return
|
|
578
|
+
current = saved
|
|
579
|
+
}
|
|
580
|
+
setPublishing(true)
|
|
581
|
+
try {
|
|
582
|
+
// The move has to be read BEFORE the transition — `acceptPage` moves
|
|
583
|
+
// the published path to the new one, after which there is nothing to
|
|
584
|
+
// detect. Same block as the old `doPublish`.
|
|
585
|
+
const move =
|
|
586
|
+
stage === 'PUBLISHED' && redirectOnPublish && canCreateRedirect
|
|
587
|
+
? pendingSlugChange(publishedPath, current.path)
|
|
588
|
+
: null
|
|
589
|
+
|
|
590
|
+
const next = await transitionPageWorkflow(current, stage, note)
|
|
591
|
+
|
|
592
|
+
if (move) {
|
|
593
|
+
const { error } = await createPublishRedirect(move)
|
|
594
|
+
if (error) {
|
|
595
|
+
// Loud, and naming both paths: the page has already moved, so a
|
|
596
|
+
// silently-failed redirect leaves exactly the broken links the
|
|
597
|
+
// switch promised to prevent.
|
|
598
|
+
toast.error(`Published, but the redirect ${move.from} → ${move.to} failed: ${error}`)
|
|
599
|
+
} else {
|
|
600
|
+
toast.success(`Redirect created: ${move.from} → ${move.to}`)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (stage === 'PUBLISHED' && next.id) await saveDirectives(next.id)
|
|
605
|
+
|
|
606
|
+
acceptPage(next)
|
|
607
|
+
if (!documentId && next.id) onNavigate(`/pages/${next.id}/edit`)
|
|
608
|
+
if (toastOk) toast.success(toastOk)
|
|
609
|
+
} catch (err) {
|
|
610
|
+
toast.error(err instanceof Error ? err.message : 'Transition failed')
|
|
611
|
+
} finally {
|
|
612
|
+
setPublishing(false)
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
[
|
|
616
|
+
page,
|
|
617
|
+
dirty,
|
|
618
|
+
documentId,
|
|
619
|
+
handleSaveDraft,
|
|
620
|
+
redirectOnPublish,
|
|
621
|
+
canCreateRedirect,
|
|
622
|
+
publishedPath,
|
|
623
|
+
acceptPage,
|
|
624
|
+
saveDirectives,
|
|
625
|
+
onNavigate,
|
|
626
|
+
],
|
|
627
|
+
)
|
|
564
628
|
|
|
565
|
-
const
|
|
629
|
+
const handleSubmitForReview = useCallback(
|
|
630
|
+
() => runWorkflow('IN_REVIEW', undefined, 'Submitted for review.'),
|
|
631
|
+
[runWorkflow],
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
const handleApprove = useCallback(() => {
|
|
566
635
|
if (!page) return
|
|
567
636
|
const result = validatePage(page, true)
|
|
568
|
-
setValidation(result.errors.length || result.warnings.length ? result : null)
|
|
569
637
|
if (result.errors.length > 0) {
|
|
570
638
|
toast.error('Resolve the listed issues before publishing')
|
|
571
639
|
return
|
|
572
640
|
}
|
|
573
|
-
|
|
574
|
-
}, [page])
|
|
641
|
+
void runWorkflow('PUBLISHED', undefined, 'Published.')
|
|
642
|
+
}, [page, runWorkflow])
|
|
643
|
+
|
|
644
|
+
const handleRequestChanges = useCallback(
|
|
645
|
+
(note: string) => runWorkflow('DRAFT', note || undefined, 'Sent back to draft.'),
|
|
646
|
+
[runWorkflow],
|
|
647
|
+
)
|
|
575
648
|
|
|
576
649
|
const requestSchedule = useCallback(async () => {
|
|
577
650
|
if (!page) return
|
|
@@ -589,8 +662,15 @@ export function PageSectionEditor({
|
|
|
589
662
|
const ok = await handleSaveDraft()
|
|
590
663
|
if (!ok) return
|
|
591
664
|
}
|
|
665
|
+
const chrome = resolveWorkflowChrome({
|
|
666
|
+
stage: page.workflowStage,
|
|
667
|
+
status: page.status,
|
|
668
|
+
role: sessionRole,
|
|
669
|
+
canEdit: effectiveCanEdit,
|
|
670
|
+
})
|
|
671
|
+
if (!chrome.showSchedule) return
|
|
592
672
|
setScheduleOpen(true)
|
|
593
|
-
}, [page, dirty, handleSaveDraft])
|
|
673
|
+
}, [page, dirty, handleSaveDraft, sessionRole, effectiveCanEdit])
|
|
594
674
|
|
|
595
675
|
const handleScheduled = useCallback(
|
|
596
676
|
(next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
|
|
@@ -609,52 +689,6 @@ export function PageSectionEditor({
|
|
|
609
689
|
[],
|
|
610
690
|
)
|
|
611
691
|
|
|
612
|
-
const doPublish = useCallback(async () => {
|
|
613
|
-
if (!page) return
|
|
614
|
-
setPublishing(true)
|
|
615
|
-
try {
|
|
616
|
-
// The move has to be read BEFORE publishing — `acceptPage` moves the
|
|
617
|
-
// published path to the new one, after which there is nothing to detect.
|
|
618
|
-
const move =
|
|
619
|
-
redirectOnPublish && canCreateRedirect ? pendingSlugChange(publishedPath, page.path) : null
|
|
620
|
-
|
|
621
|
-
const published = await publishPage(page)
|
|
622
|
-
if (published.id) await saveDirectives(published.id)
|
|
623
|
-
|
|
624
|
-
if (move) {
|
|
625
|
-
const { error } = await createPublishRedirect(move)
|
|
626
|
-
if (error) {
|
|
627
|
-
// Loud, and naming both paths: the page has already moved, so a
|
|
628
|
-
// silently-failed redirect leaves exactly the broken links the switch
|
|
629
|
-
// promised to prevent.
|
|
630
|
-
toast.error(`Published, but the redirect ${move.from} → ${move.to} failed: ${error}`)
|
|
631
|
-
} else {
|
|
632
|
-
toast.success(`Redirect created: ${move.from} → ${move.to}`)
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
acceptPage(published)
|
|
637
|
-
setDirty(false)
|
|
638
|
-
setSaveState('saved')
|
|
639
|
-
setValidation(null)
|
|
640
|
-
toast.success('Page published')
|
|
641
|
-
if (!documentId && published.id) onNavigate(`/pages/${published.id}/edit`)
|
|
642
|
-
} catch (err) {
|
|
643
|
-
toast.error(err instanceof Error ? err.message : 'Failed to publish page')
|
|
644
|
-
} finally {
|
|
645
|
-
setPublishing(false)
|
|
646
|
-
}
|
|
647
|
-
}, [
|
|
648
|
-
page,
|
|
649
|
-
documentId,
|
|
650
|
-
onNavigate,
|
|
651
|
-
saveDirectives,
|
|
652
|
-
redirectOnPublish,
|
|
653
|
-
canCreateRedirect,
|
|
654
|
-
publishedPath,
|
|
655
|
-
acceptPage,
|
|
656
|
-
])
|
|
657
|
-
|
|
658
692
|
const handleDuplicate = useCallback(async () => {
|
|
659
693
|
if (!page?.id) return
|
|
660
694
|
const { id, error } = await duplicatePage(page.id)
|
|
@@ -754,7 +788,7 @@ export function PageSectionEditor({
|
|
|
754
788
|
path: page.path,
|
|
755
789
|
trailingSlash,
|
|
756
790
|
dirty,
|
|
757
|
-
saveDraft: handleSaveDraft,
|
|
791
|
+
saveDraft: async () => Boolean(await handleSaveDraft()),
|
|
758
792
|
}).catch((err) => {
|
|
759
793
|
toast.error(err instanceof Error ? err.message : 'Failed to open the site')
|
|
760
794
|
})
|
|
@@ -833,11 +867,13 @@ export function PageSectionEditor({
|
|
|
833
867
|
<EditorTopBar
|
|
834
868
|
title={page.title}
|
|
835
869
|
status={page.status}
|
|
870
|
+
workflowStage={page.workflowStage}
|
|
871
|
+
reviewNote={page.reviewNote}
|
|
872
|
+
role={sessionRole}
|
|
836
873
|
dirty={dirty}
|
|
837
874
|
saveState={saveState}
|
|
838
875
|
publishing={publishing}
|
|
839
876
|
canEdit={effectiveCanEdit}
|
|
840
|
-
canPublish={canPublish && effectiveCanEdit}
|
|
841
877
|
publishReady={publishReady}
|
|
842
878
|
publishDisabledReason={publishDisabledReason}
|
|
843
879
|
viewport={viewport}
|
|
@@ -863,7 +899,9 @@ export function PageSectionEditor({
|
|
|
863
899
|
typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
|
|
864
900
|
}
|
|
865
901
|
onSaveDraft={handleSaveDraft}
|
|
866
|
-
|
|
902
|
+
onSubmitForReview={handleSubmitForReview}
|
|
903
|
+
onApprove={handleApprove}
|
|
904
|
+
onRequestChanges={handleRequestChanges}
|
|
867
905
|
onSchedule={page.id ? requestSchedule : undefined}
|
|
868
906
|
scheduleReady={Boolean(page.id)}
|
|
869
907
|
scheduledAt={page.scheduledAt}
|
|
@@ -1037,15 +1075,6 @@ export function PageSectionEditor({
|
|
|
1037
1075
|
onSave={handlePageSettings}
|
|
1038
1076
|
/>
|
|
1039
1077
|
|
|
1040
|
-
<ConfirmDialog
|
|
1041
|
-
open={confirmPublish}
|
|
1042
|
-
onClose={() => setConfirmPublish(false)}
|
|
1043
|
-
onConfirm={doPublish}
|
|
1044
|
-
title="Publish this page?"
|
|
1045
|
-
description="The current content will go live and replace the published version."
|
|
1046
|
-
confirmLabel="Publish"
|
|
1047
|
-
/>
|
|
1048
|
-
|
|
1049
1078
|
{page.id && (
|
|
1050
1079
|
<SchedulePublishDialog
|
|
1051
1080
|
collectionSlug={PAGES_COLLECTION}
|
|
@@ -25,9 +25,9 @@ import {
|
|
|
25
25
|
fetchPostForEditor,
|
|
26
26
|
fetchTemplateForEditor,
|
|
27
27
|
missingPostFields,
|
|
28
|
-
publishPost,
|
|
29
28
|
savePostDraft,
|
|
30
29
|
toggleSectionVisibility,
|
|
30
|
+
transitionPostWorkflow,
|
|
31
31
|
updateSectionContent,
|
|
32
32
|
updateSectionMeta,
|
|
33
33
|
validatePost,
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type PostHeaderConfig,
|
|
36
36
|
type ValidationResult,
|
|
37
37
|
} from '../../lib/post-editor-service.js'
|
|
38
|
+
import { resolveWorkflowChrome, type WorkflowStage } from '../../lib/workflow-chrome.js'
|
|
38
39
|
import { SharePreviewLinkDialog } from '../../components/SharePreviewLinkDialog.js'
|
|
39
40
|
import { defaultPostHeader } from '@prenta/core/page-sections'
|
|
40
41
|
import { openPublicSiteTab } from '../../lib/open-public-site-tab.js'
|
|
@@ -160,7 +161,6 @@ export function PostSectionEditor({
|
|
|
160
161
|
* audit so the header chip and tab dot always have a number.
|
|
161
162
|
*/
|
|
162
163
|
const [liveAudit, setLiveAudit] = useState<PageAuditResult | null>(null)
|
|
163
|
-
const [confirmPublish, setConfirmPublish] = useState(false)
|
|
164
164
|
const [scheduleOpen, setScheduleOpen] = useState(false)
|
|
165
165
|
const [shareOpen, setShareOpen] = useState(false)
|
|
166
166
|
const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
|
|
@@ -180,7 +180,8 @@ export function PostSectionEditor({
|
|
|
180
180
|
setBaseline(snapshotBaseline(p.sections, p.status))
|
|
181
181
|
}, [])
|
|
182
182
|
|
|
183
|
-
const { canEdit
|
|
183
|
+
const { canEdit } = useMemo(() => deriveCan(session), [session])
|
|
184
|
+
const sessionRole = String(session?.user?.role ?? '')
|
|
184
185
|
const customRenderers = useAdminSectionRenderers()
|
|
185
186
|
const {
|
|
186
187
|
siteUrl: publicSiteUrl,
|
|
@@ -391,61 +392,36 @@ export function PostSectionEditor({
|
|
|
391
392
|
}, [])
|
|
392
393
|
|
|
393
394
|
// ─── Save / publish ──────────────────────────────────────────────────
|
|
394
|
-
const handleSaveDraft = useCallback(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (result.errors.length > 0) {
|
|
425
|
-
toast.error('Resolve the listed issues before publishing')
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
setConfirmPublish(true)
|
|
429
|
-
}, [post])
|
|
430
|
-
|
|
431
|
-
const requestSchedule = useCallback(async () => {
|
|
432
|
-
if (!post) return
|
|
433
|
-
if (!post.id) {
|
|
434
|
-
toast.error('Save the post before scheduling')
|
|
435
|
-
return
|
|
436
|
-
}
|
|
437
|
-
const result = validatePost(post, true)
|
|
438
|
-
setValidation(result.errors.length || result.warnings.length ? result : null)
|
|
439
|
-
if (result.errors.length > 0) {
|
|
440
|
-
toast.error('Resolve the listed issues before scheduling')
|
|
441
|
-
return
|
|
442
|
-
}
|
|
443
|
-
if (dirty) {
|
|
444
|
-
const ok = await handleSaveDraft()
|
|
445
|
-
if (!ok) return
|
|
446
|
-
}
|
|
447
|
-
setScheduleOpen(true)
|
|
448
|
-
}, [post, dirty, handleSaveDraft])
|
|
395
|
+
const handleSaveDraft = useCallback(
|
|
396
|
+
async (options?: { navigate?: boolean }): Promise<EditorPost | false> => {
|
|
397
|
+
if (!post) return false
|
|
398
|
+
const result = validatePost(post, false)
|
|
399
|
+
if (result.errors.length > 0) {
|
|
400
|
+
setValidation(result)
|
|
401
|
+
toast.error(result.errors[0]!)
|
|
402
|
+
setFieldsOpen(true)
|
|
403
|
+
return false
|
|
404
|
+
}
|
|
405
|
+
setSaveState('saving')
|
|
406
|
+
try {
|
|
407
|
+
const saved = await savePostDraft(post)
|
|
408
|
+
acceptPost(saved)
|
|
409
|
+
setDirty(false)
|
|
410
|
+
setSaveState('saved')
|
|
411
|
+
setValidation(null)
|
|
412
|
+
toast.success('Draft saved')
|
|
413
|
+
if ((options?.navigate ?? true) && !documentId && saved.id) {
|
|
414
|
+
onNavigate(`/posts/${postType}/${saved.id}/edit`)
|
|
415
|
+
}
|
|
416
|
+
return saved
|
|
417
|
+
} catch (err) {
|
|
418
|
+
setSaveState('error')
|
|
419
|
+
toast.error(err instanceof Error ? err.message : 'Failed to save draft')
|
|
420
|
+
return false
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
[post, documentId, postType, onNavigate, acceptPost],
|
|
424
|
+
)
|
|
449
425
|
|
|
450
426
|
const handleScheduled = useCallback(
|
|
451
427
|
(next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
|
|
@@ -465,24 +441,6 @@ export function PostSectionEditor({
|
|
|
465
441
|
[],
|
|
466
442
|
)
|
|
467
443
|
|
|
468
|
-
const doPublish = useCallback(async () => {
|
|
469
|
-
if (!post) return
|
|
470
|
-
setPublishing(true)
|
|
471
|
-
try {
|
|
472
|
-
const published = await publishPost(post)
|
|
473
|
-
acceptPost(published)
|
|
474
|
-
setDirty(false)
|
|
475
|
-
setSaveState('saved')
|
|
476
|
-
setValidation(null)
|
|
477
|
-
toast.success('Post published')
|
|
478
|
-
if (!documentId && published.id) onNavigate(`/posts/${postType}/${published.id}/edit`)
|
|
479
|
-
} catch (err) {
|
|
480
|
-
toast.error(err instanceof Error ? err.message : 'Failed to publish post')
|
|
481
|
-
} finally {
|
|
482
|
-
setPublishing(false)
|
|
483
|
-
}
|
|
484
|
-
}, [post, documentId, postType, onNavigate])
|
|
485
|
-
|
|
486
444
|
// ─── Version history ─────────────────────────────────────────────────
|
|
487
445
|
// History is a MODE, not a dialog: it overlays the rail and drives the
|
|
488
446
|
// canvas, so the version being reviewed is editor state rather than
|
|
@@ -555,6 +513,76 @@ export function PostSectionEditor({
|
|
|
555
513
|
const effectiveCanEdit = canEdit && !reviewing
|
|
556
514
|
const canRestoreVersion = canEdit
|
|
557
515
|
|
|
516
|
+
const runWorkflow = useCallback(
|
|
517
|
+
async (stage: WorkflowStage, note?: string, toastOk?: string) => {
|
|
518
|
+
if (!post) return
|
|
519
|
+
let current = post
|
|
520
|
+
if (dirty || !current.id) {
|
|
521
|
+
const saved = await handleSaveDraft({ navigate: false })
|
|
522
|
+
if (!saved) return
|
|
523
|
+
current = saved
|
|
524
|
+
}
|
|
525
|
+
setPublishing(true)
|
|
526
|
+
try {
|
|
527
|
+
const next = await transitionPostWorkflow(current, stage, note)
|
|
528
|
+
acceptPost(next)
|
|
529
|
+
if (!documentId && next.id) onNavigate(`/posts/${postType}/${next.id}/edit`)
|
|
530
|
+
if (toastOk) toast.success(toastOk)
|
|
531
|
+
} catch (err) {
|
|
532
|
+
toast.error(err instanceof Error ? err.message : 'Transition failed')
|
|
533
|
+
} finally {
|
|
534
|
+
setPublishing(false)
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
[post, dirty, documentId, postType, handleSaveDraft, acceptPost, onNavigate],
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
const handleSubmitForReview = useCallback(
|
|
541
|
+
() => runWorkflow('IN_REVIEW', undefined, 'Submitted for review.'),
|
|
542
|
+
[runWorkflow],
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
const handleApprove = useCallback(() => {
|
|
546
|
+
if (!post) return
|
|
547
|
+
const result = validatePost(post, true)
|
|
548
|
+
if (result.errors.length > 0) {
|
|
549
|
+
toast.error('Resolve the listed issues before publishing')
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
void runWorkflow('PUBLISHED', undefined, 'Published.')
|
|
553
|
+
}, [post, runWorkflow])
|
|
554
|
+
|
|
555
|
+
const handleRequestChanges = useCallback(
|
|
556
|
+
(note: string) => runWorkflow('DRAFT', note || undefined, 'Sent back to draft.'),
|
|
557
|
+
[runWorkflow],
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
const requestSchedule = useCallback(async () => {
|
|
561
|
+
if (!post) return
|
|
562
|
+
if (!post.id) {
|
|
563
|
+
toast.error('Save the post before scheduling')
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
const result = validatePost(post, true)
|
|
567
|
+
setValidation(result.errors.length || result.warnings.length ? result : null)
|
|
568
|
+
if (result.errors.length > 0) {
|
|
569
|
+
toast.error('Resolve the listed issues before scheduling')
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
if (dirty) {
|
|
573
|
+
const ok = await handleSaveDraft()
|
|
574
|
+
if (!ok) return
|
|
575
|
+
}
|
|
576
|
+
const chrome = resolveWorkflowChrome({
|
|
577
|
+
stage: post.workflowStage,
|
|
578
|
+
status: post.status,
|
|
579
|
+
role: sessionRole,
|
|
580
|
+
canEdit: effectiveCanEdit,
|
|
581
|
+
})
|
|
582
|
+
if (!chrome.showSchedule) return
|
|
583
|
+
setScheduleOpen(true)
|
|
584
|
+
}, [post, dirty, handleSaveDraft, sessionRole, effectiveCanEdit])
|
|
585
|
+
|
|
558
586
|
const handleSeoSaved = useCallback(() => {
|
|
559
587
|
if (!post?.id) return
|
|
560
588
|
fetchPostForEditor(postType, post.id)
|
|
@@ -616,7 +644,7 @@ export function PostSectionEditor({
|
|
|
616
644
|
data: { path: (post as { path?: string }).path },
|
|
617
645
|
trailingSlash,
|
|
618
646
|
dirty,
|
|
619
|
-
saveDraft: handleSaveDraft,
|
|
647
|
+
saveDraft: async () => Boolean(await handleSaveDraft()),
|
|
620
648
|
}).catch((err) => {
|
|
621
649
|
toast.error(err instanceof Error ? err.message : 'Failed to open the site')
|
|
622
650
|
})
|
|
@@ -688,11 +716,13 @@ export function PostSectionEditor({
|
|
|
688
716
|
<EditorTopBar
|
|
689
717
|
title={post.title || `New ${typeLabel}`}
|
|
690
718
|
status={post.status}
|
|
719
|
+
workflowStage={post.workflowStage}
|
|
720
|
+
reviewNote={post.reviewNote}
|
|
721
|
+
role={sessionRole}
|
|
691
722
|
dirty={dirty}
|
|
692
723
|
saveState={saveState}
|
|
693
724
|
publishing={publishing}
|
|
694
725
|
canEdit={effectiveCanEdit}
|
|
695
|
-
canPublish={canPublish && effectiveCanEdit}
|
|
696
726
|
publishReady={publishReady}
|
|
697
727
|
publishDisabledReason={publishDisabledReason}
|
|
698
728
|
viewport={viewport}
|
|
@@ -716,7 +746,9 @@ export function PostSectionEditor({
|
|
|
716
746
|
typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
|
|
717
747
|
}
|
|
718
748
|
onSaveDraft={handleSaveDraft}
|
|
719
|
-
|
|
749
|
+
onSubmitForReview={handleSubmitForReview}
|
|
750
|
+
onApprove={handleApprove}
|
|
751
|
+
onRequestChanges={handleRequestChanges}
|
|
720
752
|
onSchedule={post.id ? requestSchedule : undefined}
|
|
721
753
|
scheduleReady={Boolean(post.id)}
|
|
722
754
|
scheduledAt={post.scheduledAt}
|
|
@@ -889,15 +921,6 @@ export function PostSectionEditor({
|
|
|
889
921
|
onSave={handleFields}
|
|
890
922
|
/>
|
|
891
923
|
|
|
892
|
-
<ConfirmDialog
|
|
893
|
-
open={confirmPublish}
|
|
894
|
-
onClose={() => setConfirmPublish(false)}
|
|
895
|
-
onConfirm={doPublish}
|
|
896
|
-
title="Publish this post?"
|
|
897
|
-
description="The current content will go live and replace the published version."
|
|
898
|
-
confirmLabel="Publish"
|
|
899
|
-
/>
|
|
900
|
-
|
|
901
924
|
{post.id && (
|
|
902
925
|
<SchedulePublishDialog
|
|
903
926
|
collectionSlug={postType}
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
SeoEmptyState,
|
|
20
20
|
SeoErrorState,
|
|
21
21
|
gradeMeta,
|
|
22
|
-
btnSecondary,
|
|
23
22
|
} from '../../components/seo/primitives.js'
|
|
24
23
|
import type { SeoResource } from './useSeoResource.js'
|
|
25
24
|
|
|
@@ -36,11 +35,9 @@ function impactTone(impact: QuickWin['impact']): 'good' | 'fair' | 'poor' {
|
|
|
36
35
|
export function OverviewTab({
|
|
37
36
|
resource,
|
|
38
37
|
onOpenIssue,
|
|
39
|
-
onConnectSearchConsole,
|
|
40
38
|
}: {
|
|
41
39
|
resource: SeoResource<SeoOverview>
|
|
42
40
|
onOpenIssue?: (issueId: string) => void
|
|
43
|
-
onConnectSearchConsole?: () => void
|
|
44
41
|
}) {
|
|
45
42
|
const { data, loading, error, refetch } = resource
|
|
46
43
|
|
|
@@ -203,14 +200,7 @@ export function OverviewTab({
|
|
|
203
200
|
<SeoEmptyState
|
|
204
201
|
icon={<BarChart3 size={24} />}
|
|
205
202
|
title="Search Console not connected"
|
|
206
|
-
description="
|
|
207
|
-
action={
|
|
208
|
-
onConnectSearchConsole ? (
|
|
209
|
-
<button className={btnSecondary} onClick={onConnectSearchConsole}>
|
|
210
|
-
Connect Search Console
|
|
211
|
-
</button>
|
|
212
|
-
) : undefined
|
|
213
|
-
}
|
|
203
|
+
description="Add a Google Search Console verification token in Settings → SEO. Performance charts will appear here when the Search Analytics API is wired."
|
|
214
204
|
/>
|
|
215
205
|
) : searchPerformance.length === 0 ? (
|
|
216
206
|
<SeoEmptyState title="No search performance data" />
|