@prenta/admin 1.5.1 → 1.6.1
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 +12 -0
- 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__/lib/page-audit-input.test.js +10 -0
- package/dist/__tests__/lib/page-audit-input.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 +68 -0
- package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +27 -6
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/version-history.test.js +27 -0
- package/dist/__tests__/lib/version-history.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__/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 +21 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +3 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-tab.render.test.js +3 -0
- package/dist/__tests__/views/post-tab.render.test.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/lib/page-audit-input.js +1 -1
- package/dist/lib/page-audit-input.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 +24 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +49 -24
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/version-history.d.ts.map +1 -1
- package/dist/lib/version-history.js +3 -9
- package/dist/lib/version-history.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/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/PostFieldsForm.d.ts +3 -0
- package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsForm.js +7 -2
- package/dist/views/post-editor/PostFieldsForm.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +15 -2
- 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 +81 -65
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTab.d.ts.map +1 -1
- package/dist/views/post-editor/PostTab.js +1 -0
- package/dist/views/post-editor/PostTab.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
- package/src/__tests__/lib/page-audit-input.test.ts +11 -0
- package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
- package/src/__tests__/lib/post-editor-service-api.test.ts +74 -0
- package/src/__tests__/lib/post-editor-service.test.ts +28 -9
- package/src/__tests__/lib/version-history.test.ts +29 -0
- package/src/__tests__/lib/workflow-chrome.test.ts +125 -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 +41 -0
- package/src/__tests__/views/post-section-editor.test.tsx +3 -0
- package/src/__tests__/views/post-tab.render.test.tsx +3 -0
- package/src/components/RequestChangesDialog.tsx +56 -0
- package/src/lib/page-audit-input.ts +1 -1
- package/src/lib/page-editor-service.ts +26 -11
- package/src/lib/post-editor-service.ts +71 -20
- package/src/lib/version-history.ts +3 -7
- package/src/lib/workflow-chrome.ts +105 -0
- package/src/views/page-editor/EditorTopBar.tsx +110 -48
- package/src/views/page-editor/PageSectionEditor.tsx +121 -92
- package/src/views/post-editor/PostFieldsForm.tsx +14 -5
- package/src/views/post-editor/PostFieldsModal.tsx +15 -2
- package/src/views/post-editor/PostSectionEditor.tsx +114 -91
- package/src/views/post-editor/PostTab.tsx +1 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { useState, type ReactNode } from 'react'
|
|
3
4
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
4
5
|
import {
|
|
5
6
|
Calendar,
|
|
@@ -14,11 +15,20 @@ import {
|
|
|
14
15
|
} from 'lucide-react'
|
|
15
16
|
import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
|
|
16
17
|
import { BreakpointPicker } from '../../components/BreakpointPicker.js'
|
|
18
|
+
import { RequestChangesDialog } from '../../components/RequestChangesDialog.js'
|
|
17
19
|
import { ZoomChip } from '../../components/ZoomChip.js'
|
|
18
20
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
19
21
|
import type { PageStatus } from '../../lib/page-editor-service.js'
|
|
20
22
|
import type { Zoom } from '../../lib/breakpoints.js'
|
|
21
23
|
import { resolveViewSiteChrome, type ViewSiteIntent } from '../../lib/view-site-chrome.js'
|
|
24
|
+
import {
|
|
25
|
+
APPROVE_LABEL,
|
|
26
|
+
REQUEST_CHANGES_LABEL,
|
|
27
|
+
REVIEWER_NOTE_PREFIX,
|
|
28
|
+
SUBMIT_FOR_REVIEW_LABEL,
|
|
29
|
+
resolveWorkflowChrome,
|
|
30
|
+
type WorkflowStage,
|
|
31
|
+
} from '../../lib/workflow-chrome.js'
|
|
22
32
|
import { type ViewportId } from './viewports.js'
|
|
23
33
|
|
|
24
34
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
|
@@ -26,14 +36,16 @@ export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
|
|
26
36
|
interface EditorTopBarProps {
|
|
27
37
|
title: string
|
|
28
38
|
status: PageStatus
|
|
39
|
+
workflowStage: WorkflowStage
|
|
40
|
+
reviewNote?: string | null
|
|
41
|
+
role: string
|
|
29
42
|
dirty: boolean
|
|
30
43
|
saveState: SaveState
|
|
31
44
|
publishing: boolean
|
|
32
45
|
canEdit: boolean
|
|
33
|
-
|
|
34
|
-
/** When false, Publish stays disabled even if the user has publish permission. */
|
|
46
|
+
/** When false, Approve stays disabled even if the user can review. */
|
|
35
47
|
publishReady?: boolean
|
|
36
|
-
/** Shown on hover/focus when
|
|
48
|
+
/** Shown on hover/focus when approve is blocked by validation (not role). */
|
|
37
49
|
publishDisabledReason?: string
|
|
38
50
|
viewport: ViewportId
|
|
39
51
|
/** Canvas zoom mode; omit to hide the zoom chip. */
|
|
@@ -82,7 +94,9 @@ interface EditorTopBarProps {
|
|
|
82
94
|
/** Band for `seoScore`; omit to derive from the content-analysis scale. */
|
|
83
95
|
seoScoreBand?: 'good' | 'fair' | 'poor'
|
|
84
96
|
onSaveDraft: () => void
|
|
85
|
-
|
|
97
|
+
onSubmitForReview: () => void
|
|
98
|
+
onApprove: () => void
|
|
99
|
+
onRequestChanges: (note: string) => void
|
|
86
100
|
/** Open the schedule-publish dialog. Omitted when the document is unsaved. */
|
|
87
101
|
onSchedule?: () => void
|
|
88
102
|
/** When false, Schedule stays disabled (e.g. unsaved document). Defaults to true. */
|
|
@@ -97,21 +111,16 @@ interface EditorTopBarProps {
|
|
|
97
111
|
structuralTypes?: string[]
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
const STATUS_TEXT: Record<PageStatus, { label: string; className: string }> = {
|
|
101
|
-
DRAFT: { label: 'Draft', className: 'text-warning dark:text-amber-400' },
|
|
102
|
-
PUBLISHED: { label: 'Published', className: 'text-emerald-600 dark:text-emerald-400' },
|
|
103
|
-
SCHEDULED: { label: 'Scheduled', className: 'text-warning dark:text-amber-400' },
|
|
104
|
-
ARCHIVED: { label: 'Archived', className: 'text-muted-foreground' },
|
|
105
|
-
}
|
|
106
|
-
|
|
107
114
|
export function EditorTopBar({
|
|
108
115
|
title,
|
|
109
116
|
status,
|
|
117
|
+
workflowStage,
|
|
118
|
+
reviewNote,
|
|
119
|
+
role,
|
|
110
120
|
dirty,
|
|
111
121
|
saveState,
|
|
112
122
|
publishing,
|
|
113
123
|
canEdit,
|
|
114
|
-
canPublish,
|
|
115
124
|
publishReady = true,
|
|
116
125
|
publishDisabledReason,
|
|
117
126
|
viewport,
|
|
@@ -133,13 +142,16 @@ export function EditorTopBar({
|
|
|
133
142
|
seoScore,
|
|
134
143
|
seoScoreBand,
|
|
135
144
|
onSaveDraft,
|
|
136
|
-
|
|
145
|
+
onSubmitForReview,
|
|
146
|
+
onApprove,
|
|
147
|
+
onRequestChanges,
|
|
137
148
|
onSchedule,
|
|
138
149
|
scheduleReady = true,
|
|
139
150
|
scheduledAt = null,
|
|
140
151
|
scheduledUnpublishAt = null,
|
|
141
152
|
}: EditorTopBarProps) {
|
|
142
|
-
const
|
|
153
|
+
const [requestChangesOpen, setRequestChangesOpen] = useState(false)
|
|
154
|
+
const workflow = resolveWorkflowChrome({ stage: workflowStage, status, role, canEdit })
|
|
143
155
|
const chrome = onViewOnSite
|
|
144
156
|
? resolveViewSiteChrome({
|
|
145
157
|
status,
|
|
@@ -150,36 +162,74 @@ export function EditorTopBar({
|
|
|
150
162
|
})
|
|
151
163
|
: null
|
|
152
164
|
|
|
153
|
-
const
|
|
154
|
-
const
|
|
165
|
+
const approveBlocked = !publishReady || publishing || saveState === 'saving'
|
|
166
|
+
const approveTitle = publishing
|
|
155
167
|
? 'Publishing…'
|
|
156
168
|
: saveState === 'saving'
|
|
157
169
|
? 'Wait for the current save to finish'
|
|
158
|
-
:
|
|
170
|
+
: !publishReady && publishDisabledReason
|
|
159
171
|
? publishDisabledReason
|
|
160
|
-
:
|
|
161
|
-
? 'You do not have permission to publish'
|
|
162
|
-
: undefined
|
|
172
|
+
: undefined
|
|
163
173
|
|
|
164
|
-
const scheduleBlocked =
|
|
165
|
-
!onSchedule ||
|
|
166
|
-
!canPublish ||
|
|
167
|
-
!publishReady ||
|
|
168
|
-
!scheduleReady ||
|
|
169
|
-
publishing ||
|
|
170
|
-
saveState === 'saving'
|
|
174
|
+
const scheduleBlocked = !publishReady || !scheduleReady || publishing || saveState === 'saving'
|
|
171
175
|
const scheduleTitle =
|
|
172
176
|
saveState === 'saving'
|
|
173
177
|
? 'Wait for the current save to finish'
|
|
174
178
|
: !scheduleReady
|
|
175
179
|
? 'Save before scheduling'
|
|
176
|
-
:
|
|
180
|
+
: !publishReady && publishDisabledReason
|
|
177
181
|
? publishDisabledReason
|
|
178
|
-
:
|
|
179
|
-
?
|
|
180
|
-
:
|
|
181
|
-
|
|
182
|
-
|
|
182
|
+
: scheduledAt
|
|
183
|
+
? `Reschedule — currently publishes ${formatScheduleLabel(scheduledAt)}`
|
|
184
|
+
: 'Schedule a future publish date'
|
|
185
|
+
|
|
186
|
+
const submitBlocked = !canEdit || publishing || saveState === 'saving'
|
|
187
|
+
const requestChangesBlocked = publishing || saveState === 'saving'
|
|
188
|
+
let primaryAction: ReactNode
|
|
189
|
+
switch (workflow.primary) {
|
|
190
|
+
case 'submit':
|
|
191
|
+
primaryAction = (
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={onSubmitForReview}
|
|
195
|
+
disabled={submitBlocked}
|
|
196
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
197
|
+
>
|
|
198
|
+
{publishing && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
|
|
199
|
+
{SUBMIT_FOR_REVIEW_LABEL}
|
|
200
|
+
</button>
|
|
201
|
+
)
|
|
202
|
+
break
|
|
203
|
+
case 'approve':
|
|
204
|
+
primaryAction = (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={onApprove}
|
|
208
|
+
disabled={approveBlocked}
|
|
209
|
+
title={approveTitle}
|
|
210
|
+
aria-disabled={approveBlocked}
|
|
211
|
+
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
212
|
+
>
|
|
213
|
+
{publishing && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
|
|
214
|
+
{APPROVE_LABEL}
|
|
215
|
+
</button>
|
|
216
|
+
)
|
|
217
|
+
break
|
|
218
|
+
case 'waiting':
|
|
219
|
+
primaryAction = (
|
|
220
|
+
<span className="text-muted-foreground text-sm" aria-live="polite">
|
|
221
|
+
{workflow.waitingLabel}
|
|
222
|
+
</span>
|
|
223
|
+
)
|
|
224
|
+
break
|
|
225
|
+
case null:
|
|
226
|
+
primaryAction = null
|
|
227
|
+
break
|
|
228
|
+
default: {
|
|
229
|
+
const _exhaustive: never = workflow.primary
|
|
230
|
+
primaryAction = _exhaustive
|
|
231
|
+
}
|
|
232
|
+
}
|
|
183
233
|
|
|
184
234
|
return (
|
|
185
235
|
<header className="prenta-editor-header bg-card border-border flex h-14 shrink-0 items-center justify-between gap-4 border-b px-4">
|
|
@@ -199,11 +249,16 @@ export function EditorTopBar({
|
|
|
199
249
|
<span className="text-foreground truncate font-medium">{title || 'Untitled page'}</span>
|
|
200
250
|
</nav>
|
|
201
251
|
<span
|
|
202
|
-
className={`ml-2 shrink-0 text-xs font-medium ${
|
|
203
|
-
aria-label={`Status: ${
|
|
252
|
+
className={`ml-2 shrink-0 text-xs font-medium ${workflow.statusClassName}`}
|
|
253
|
+
aria-label={`Status: ${workflow.statusLabel}`}
|
|
204
254
|
>
|
|
205
|
-
{
|
|
255
|
+
{workflow.statusLabel}
|
|
206
256
|
</span>
|
|
257
|
+
{reviewNote ? (
|
|
258
|
+
<span className="text-muted-foreground text-xs">
|
|
259
|
+
{REVIEWER_NOTE_PREFIX} {reviewNote}
|
|
260
|
+
</span>
|
|
261
|
+
) : null}
|
|
207
262
|
{scheduledAt && (
|
|
208
263
|
<span
|
|
209
264
|
className="text-muted-foreground ml-1 hidden shrink-0 text-xs sm:inline"
|
|
@@ -351,7 +406,7 @@ export function EditorTopBar({
|
|
|
351
406
|
{saveState === 'saving' ? 'Saving…' : 'Save Draft'}
|
|
352
407
|
</button>
|
|
353
408
|
|
|
354
|
-
{onSchedule && (
|
|
409
|
+
{onSchedule && workflow.showSchedule && (
|
|
355
410
|
<button
|
|
356
411
|
type="button"
|
|
357
412
|
onClick={onSchedule}
|
|
@@ -365,17 +420,24 @@ export function EditorTopBar({
|
|
|
365
420
|
</button>
|
|
366
421
|
)}
|
|
367
422
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
423
|
+
{primaryAction}
|
|
424
|
+
|
|
425
|
+
{workflow.showRequestChanges && (
|
|
426
|
+
<button
|
|
427
|
+
type="button"
|
|
428
|
+
onClick={() => setRequestChangesOpen(true)}
|
|
429
|
+
disabled={requestChangesBlocked}
|
|
430
|
+
className="border-border text-foreground hover:bg-accent rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
431
|
+
>
|
|
432
|
+
{REQUEST_CHANGES_LABEL}
|
|
433
|
+
</button>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
<RequestChangesDialog
|
|
437
|
+
open={requestChangesOpen}
|
|
438
|
+
onClose={() => setRequestChangesOpen(false)}
|
|
439
|
+
onConfirm={onRequestChanges}
|
|
440
|
+
/>
|
|
379
441
|
</div>
|
|
380
442
|
</header>
|
|
381
443
|
)
|
|
@@ -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}
|
|
@@ -23,7 +23,10 @@ export interface PostFieldsFormValues {
|
|
|
23
23
|
title: string
|
|
24
24
|
slug: string
|
|
25
25
|
excerpt: string
|
|
26
|
+
/** Persist identity (media id when known). */
|
|
26
27
|
featuredImage: string
|
|
28
|
+
/** Public URL shown in the input and used as `<img src>`. */
|
|
29
|
+
featuredImageUrl: string
|
|
27
30
|
category: string
|
|
28
31
|
tags: string[]
|
|
29
32
|
}
|
|
@@ -286,10 +289,13 @@ export function PostFieldsForm({
|
|
|
286
289
|
<input
|
|
287
290
|
id={imageId}
|
|
288
291
|
className={`${INPUT} min-w-0 flex-1`}
|
|
289
|
-
value={values.
|
|
292
|
+
value={values.featuredImageUrl}
|
|
290
293
|
disabled={!canEdit}
|
|
291
294
|
placeholder="https://… or pick from the media library"
|
|
292
|
-
onChange={(e) =>
|
|
295
|
+
onChange={(e) => {
|
|
296
|
+
const next = e.target.value
|
|
297
|
+
onChange({ featuredImage: next, featuredImageUrl: next })
|
|
298
|
+
}}
|
|
293
299
|
/>
|
|
294
300
|
<button
|
|
295
301
|
type="button"
|
|
@@ -300,9 +306,9 @@ export function PostFieldsForm({
|
|
|
300
306
|
Browse
|
|
301
307
|
</button>
|
|
302
308
|
</div>
|
|
303
|
-
{values.
|
|
309
|
+
{values.featuredImageUrl && (
|
|
304
310
|
<div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
|
|
305
|
-
<img src={values.
|
|
311
|
+
<img src={values.featuredImageUrl} alt="" className="h-28 w-full object-cover" />
|
|
306
312
|
</div>
|
|
307
313
|
)}
|
|
308
314
|
</div>
|
|
@@ -311,8 +317,11 @@ export function PostFieldsForm({
|
|
|
311
317
|
<MediaPickerModal
|
|
312
318
|
open={mediaOpen}
|
|
313
319
|
onClose={() => setMediaOpen(false)}
|
|
320
|
+
onSelectItem={(item) => {
|
|
321
|
+
onChange({ featuredImage: item.id || item.url, featuredImageUrl: item.url })
|
|
322
|
+
}}
|
|
314
323
|
onSelect={(url) => {
|
|
315
|
-
onChange({
|
|
324
|
+
onChange({ featuredImageUrl: url })
|
|
316
325
|
setMediaOpen(false)
|
|
317
326
|
}}
|
|
318
327
|
accept="image/*"
|
|
@@ -29,6 +29,7 @@ type FormSnapshot = {
|
|
|
29
29
|
slug: string
|
|
30
30
|
excerpt: string
|
|
31
31
|
featuredImage: string
|
|
32
|
+
featuredImageUrl: string
|
|
32
33
|
category: string
|
|
33
34
|
tags: string
|
|
34
35
|
}
|
|
@@ -39,6 +40,7 @@ function snapshotFromPost(post: EditorPost): FormSnapshot {
|
|
|
39
40
|
slug: post.slug,
|
|
40
41
|
excerpt: post.excerpt,
|
|
41
42
|
featuredImage: post.featuredImage,
|
|
43
|
+
featuredImageUrl: post.featuredImageUrl,
|
|
42
44
|
category: post.category,
|
|
43
45
|
tags: [...post.tags].sort().join('\0'),
|
|
44
46
|
}
|
|
@@ -64,6 +66,7 @@ export function PostFieldsModal({
|
|
|
64
66
|
const [slug, setSlug] = useState(post.slug)
|
|
65
67
|
const [excerpt, setExcerpt] = useState(post.excerpt)
|
|
66
68
|
const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
|
|
69
|
+
const [featuredImageUrl, setFeaturedImageUrl] = useState(post.featuredImageUrl)
|
|
67
70
|
const [category, setCategory] = useState(post.category)
|
|
68
71
|
const [tags, setTags] = useState<string[]>(post.tags)
|
|
69
72
|
const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
|
|
@@ -82,13 +85,22 @@ export function PostFieldsModal({
|
|
|
82
85
|
setSlug(post.slug)
|
|
83
86
|
setExcerpt(post.excerpt)
|
|
84
87
|
setFeaturedImage(post.featuredImage)
|
|
88
|
+
setFeaturedImageUrl(post.featuredImageUrl)
|
|
85
89
|
setCategory(post.category)
|
|
86
90
|
setTags(post.tags)
|
|
87
91
|
setSlugTouched(Boolean(post.slug))
|
|
88
92
|
baselineRef.current = snapshotFromPost(post)
|
|
89
93
|
}, [open, post])
|
|
90
94
|
|
|
91
|
-
const values: PostFieldsFormValues = {
|
|
95
|
+
const values: PostFieldsFormValues = {
|
|
96
|
+
title,
|
|
97
|
+
slug,
|
|
98
|
+
excerpt,
|
|
99
|
+
featuredImage,
|
|
100
|
+
featuredImageUrl,
|
|
101
|
+
category,
|
|
102
|
+
tags,
|
|
103
|
+
}
|
|
92
104
|
|
|
93
105
|
const isDirty = useMemo(() => {
|
|
94
106
|
if (!open || !baselineRef.current) return false
|
|
@@ -108,6 +120,7 @@ export function PostFieldsModal({
|
|
|
108
120
|
if (patch.slug !== undefined) setSlug(patch.slug)
|
|
109
121
|
if (patch.excerpt !== undefined) setExcerpt(patch.excerpt)
|
|
110
122
|
if (patch.featuredImage !== undefined) setFeaturedImage(patch.featuredImage)
|
|
123
|
+
if (patch.featuredImageUrl !== undefined) setFeaturedImageUrl(patch.featuredImageUrl)
|
|
111
124
|
if (patch.category !== undefined) setCategory(patch.category)
|
|
112
125
|
if (patch.tags !== undefined) setTags(patch.tags)
|
|
113
126
|
}
|
|
@@ -116,7 +129,7 @@ export function PostFieldsModal({
|
|
|
116
129
|
|
|
117
130
|
function handleSave() {
|
|
118
131
|
if (slugError) return
|
|
119
|
-
onSave({ title, slug, excerpt, featuredImage, category, tags })
|
|
132
|
+
onSave({ title, slug, excerpt, featuredImage, featuredImageUrl, category, tags })
|
|
120
133
|
onClose()
|
|
121
134
|
}
|
|
122
135
|
|