@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +5 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts +2 -0
  6. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/request-changes-dialog.render.test.js +12 -0
  8. package/dist/__tests__/components/request-changes-dialog.render.test.js.map +1 -0
  9. package/dist/__tests__/layout/sidebar-posts.test.js +1 -0
  10. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  11. package/dist/__tests__/lib/page-editor-service-api.test.js +28 -15
  12. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  13. package/dist/__tests__/lib/post-editor-service-api.test.d.ts +2 -0
  14. package/dist/__tests__/lib/post-editor-service-api.test.d.ts.map +1 -0
  15. package/dist/__tests__/lib/post-editor-service-api.test.js +67 -0
  16. package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
  17. package/dist/__tests__/lib/post-editor-service.test.js +2 -0
  18. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  19. package/dist/__tests__/lib/workflow-chrome.test.d.ts +2 -0
  20. package/dist/__tests__/lib/workflow-chrome.test.d.ts.map +1 -0
  21. package/dist/__tests__/lib/workflow-chrome.test.js +108 -0
  22. package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -0
  23. package/dist/__tests__/router/admin-routes.test.js +1 -0
  24. package/dist/__tests__/router/admin-routes.test.js.map +1 -1
  25. package/dist/__tests__/views/editor-top-bar.render.test.js +40 -33
  26. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  27. package/dist/__tests__/views/page-section-editor.test.js +8 -0
  28. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  29. package/dist/__tests__/views/page-tab.render.test.js +2 -0
  30. package/dist/__tests__/views/page-tab.render.test.js.map +1 -1
  31. package/dist/__tests__/views/post-fields-modal.render.test.js +2 -0
  32. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  33. package/dist/__tests__/views/post-section-editor.test.js +2 -0
  34. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  35. package/dist/__tests__/views/post-tab.render.test.js +2 -0
  36. package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
  37. package/dist/__tests__/views/trash.render.test.d.ts +2 -0
  38. package/dist/__tests__/views/trash.render.test.d.ts.map +1 -0
  39. package/dist/__tests__/views/trash.render.test.js +55 -0
  40. package/dist/__tests__/views/trash.render.test.js.map +1 -0
  41. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  42. package/dist/components/Breadcrumbs.js +1 -0
  43. package/dist/components/Breadcrumbs.js.map +1 -1
  44. package/dist/components/RequestChangesDialog.d.ts +6 -0
  45. package/dist/components/RequestChangesDialog.d.ts.map +1 -0
  46. package/dist/components/RequestChangesDialog.js +15 -0
  47. package/dist/components/RequestChangesDialog.js.map +1 -0
  48. package/dist/layout/Sidebar.d.ts.map +1 -1
  49. package/dist/layout/Sidebar.js +3 -2
  50. package/dist/layout/Sidebar.js.map +1 -1
  51. package/dist/lib/page-editor-service.d.ts +6 -2
  52. package/dist/lib/page-editor-service.d.ts.map +1 -1
  53. package/dist/lib/page-editor-service.js +16 -12
  54. package/dist/lib/page-editor-service.js.map +1 -1
  55. package/dist/lib/post-editor-service.d.ts +6 -1
  56. package/dist/lib/post-editor-service.d.ts.map +1 -1
  57. package/dist/lib/post-editor-service.js +16 -11
  58. package/dist/lib/post-editor-service.js.map +1 -1
  59. package/dist/lib/workflow-chrome.d.ts +22 -0
  60. package/dist/lib/workflow-chrome.d.ts.map +1 -0
  61. package/dist/lib/workflow-chrome.js +80 -0
  62. package/dist/lib/workflow-chrome.js.map +1 -0
  63. package/dist/prenta-admin.css +1 -1
  64. package/dist/router/admin-routes.d.ts.map +1 -1
  65. package/dist/router/admin-routes.js +2 -0
  66. package/dist/router/admin-routes.js.map +1 -1
  67. package/dist/views/ApiKeys.js +1 -5
  68. package/dist/views/ApiKeys.js.map +1 -1
  69. package/dist/views/SEO.d.ts.map +1 -1
  70. package/dist/views/SEO.js +1 -1
  71. package/dist/views/SEO.js.map +1 -1
  72. package/dist/views/Trash.d.ts +18 -0
  73. package/dist/views/Trash.d.ts.map +1 -0
  74. package/dist/views/Trash.js +104 -0
  75. package/dist/views/Trash.js.map +1 -0
  76. package/dist/views/page-editor/EditorTopBar.d.ts +10 -5
  77. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  78. package/dist/views/page-editor/EditorTopBar.js +38 -28
  79. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  80. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  81. package/dist/views/page-editor/PageSectionEditor.js +83 -62
  82. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  83. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  84. package/dist/views/post-editor/PostSectionEditor.js +78 -62
  85. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  86. package/dist/views/seo/OverviewTab.d.ts +1 -2
  87. package/dist/views/seo/OverviewTab.d.ts.map +1 -1
  88. package/dist/views/seo/OverviewTab.js +3 -3
  89. package/dist/views/seo/OverviewTab.js.map +1 -1
  90. package/package.json +4 -4
  91. package/src/AdminRoot.tsx +6 -0
  92. package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
  93. package/src/__tests__/layout/sidebar-posts.test.ts +1 -0
  94. package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
  95. package/src/__tests__/lib/post-editor-service-api.test.ts +73 -0
  96. package/src/__tests__/lib/post-editor-service.test.ts +2 -0
  97. package/src/__tests__/lib/workflow-chrome.test.ts +125 -0
  98. package/src/__tests__/router/admin-routes.test.ts +1 -0
  99. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -45
  100. package/src/__tests__/views/page-section-editor.test.tsx +18 -0
  101. package/src/__tests__/views/page-tab.render.test.tsx +2 -0
  102. package/src/__tests__/views/post-fields-modal.render.test.tsx +2 -0
  103. package/src/__tests__/views/post-section-editor.test.tsx +2 -0
  104. package/src/__tests__/views/post-tab.render.test.tsx +2 -0
  105. package/src/__tests__/views/trash.render.test.tsx +61 -0
  106. package/src/components/Breadcrumbs.tsx +1 -0
  107. package/src/components/RequestChangesDialog.tsx +56 -0
  108. package/src/layout/Sidebar.tsx +3 -0
  109. package/src/lib/page-editor-service.ts +26 -11
  110. package/src/lib/post-editor-service.ts +26 -10
  111. package/src/lib/workflow-chrome.ts +105 -0
  112. package/src/router/admin-routes.ts +2 -0
  113. package/src/views/ApiKeys.tsx +0 -13
  114. package/src/views/SEO.tsx +0 -5
  115. package/src/views/Trash.tsx +207 -0
  116. package/src/views/page-editor/EditorTopBar.tsx +110 -48
  117. package/src/views/page-editor/PageSectionEditor.tsx +121 -92
  118. package/src/views/post-editor/PostSectionEditor.tsx +111 -88
  119. package/src/views/seo/OverviewTab.tsx +1 -11
@@ -285,7 +285,6 @@ interface CreateApiKeyDialogProps {
285
285
  function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProps) {
286
286
  const [name, setName] = useState('')
287
287
  const [preset, setPreset] = useState<'admin' | 'content' | 'readonly' | 'custom'>('content')
288
- const [pageBuilder, setPageBuilder] = useState(true)
289
288
  const [media, setMedia] = useState(true)
290
289
  const [expiresInDays, setExpiresInDays] = useState<string>('')
291
290
  const [password, setPassword] = useState('')
@@ -299,7 +298,6 @@ function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProp
299
298
  actions: ['read'],
300
299
  globals: ['*'],
301
300
  media: false,
302
- pageBuilder: false,
303
301
  }
304
302
  }
305
303
  if (preset === 'content') {
@@ -308,14 +306,12 @@ function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProp
308
306
  actions: ['read', 'create', 'update'],
309
307
  globals: ['*'],
310
308
  media,
311
- pageBuilder,
312
309
  }
313
310
  }
314
311
  return {
315
312
  collections: ['*'],
316
313
  actions: ['read'],
317
314
  media,
318
- pageBuilder,
319
315
  }
320
316
  }
321
317
 
@@ -418,15 +414,6 @@ function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProp
418
414
  />
419
415
  Allow media uploads
420
416
  </label>
421
- <label className="text-foreground flex items-center gap-2 text-sm">
422
- <input
423
- type="checkbox"
424
- checked={pageBuilder}
425
- onChange={(e) => setPageBuilder(e.target.checked)}
426
- className="accent-brand h-4 w-4"
427
- />
428
- Allow AI page generation
429
- </label>
430
417
  </div>
431
418
  )}
432
419
 
package/src/views/SEO.tsx CHANGED
@@ -241,11 +241,6 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
241
241
  <OverviewTab
242
242
  resource={overviewResource}
243
243
  onOpenIssue={(issueId) => onNavigate?.(`/seo?tab=audit&issueId=${issueId}`)}
244
- onConnectSearchConsole={() =>
245
- toast.info(
246
- 'Google Search Console integration is coming soon. Demo performance data is available in local development.',
247
- )
248
- }
249
244
  />
250
245
  )}
251
246
  </Tabs.Content>
@@ -0,0 +1,207 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { Loader2, RotateCcw, Trash2 } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import { cmsApi } from '../lib/api.js'
7
+ import { EmptyState } from '../components/ui/EmptyState.js'
8
+
9
+ export interface TrashProps {
10
+ onNavigate: (path: string) => void
11
+ config?: {
12
+ collections?:
13
+ | Record<string, { slug?: string; admin?: { hidden?: boolean } } | undefined>
14
+ | Array<{ slug?: string; admin?: { hidden?: boolean } }>
15
+ }
16
+ }
17
+
18
+ interface TrashRow {
19
+ id: string
20
+ collection: string
21
+ title: string
22
+ slug: string | null
23
+ deletedAt: string | null
24
+ updatedAt: string | null
25
+ }
26
+
27
+ interface ListEnvelope {
28
+ docs?: Array<Record<string, unknown>>
29
+ }
30
+
31
+ function editorPath(collection: string, id: string): string {
32
+ if (collection === 'pages') return `/pages/${id}`
33
+ if (collection === 'posts') return `/posts/posts/${id}/edit`
34
+ return `/collections/${collection}/${id}`
35
+ }
36
+
37
+ function formatWhen(value: string | null): string {
38
+ if (!value) return '—'
39
+ const date = new Date(value)
40
+ if (Number.isNaN(date.getTime())) return '—'
41
+ return date.toLocaleString(undefined, {
42
+ month: 'short',
43
+ day: 'numeric',
44
+ year: 'numeric',
45
+ hour: 'numeric',
46
+ minute: '2-digit',
47
+ })
48
+ }
49
+
50
+ function asRow(collection: string, doc: Record<string, unknown>): TrashRow {
51
+ const data = doc.data && typeof doc.data === 'object' ? (doc.data as Record<string, unknown>) : {}
52
+ const title =
53
+ (typeof doc.title === 'string' && doc.title) ||
54
+ (typeof data.title === 'string' && data.title) ||
55
+ 'Untitled'
56
+ const slug =
57
+ (typeof doc.slug === 'string' && doc.slug) ||
58
+ (typeof data.slug === 'string' && data.slug) ||
59
+ null
60
+ return {
61
+ id: String(doc.id ?? ''),
62
+ collection,
63
+ title,
64
+ slug,
65
+ deletedAt: typeof doc.deletedAt === 'string' ? doc.deletedAt : null,
66
+ updatedAt: typeof doc.updatedAt === 'string' ? doc.updatedAt : null,
67
+ }
68
+ }
69
+
70
+ export function Trash({ onNavigate, config }: TrashProps) {
71
+ const collections = useMemo(() => {
72
+ const raw = config?.collections
73
+ const list = Array.isArray(raw) ? raw : Object.values(raw ?? {})
74
+ const fromConfig = list
75
+ .filter((c): c is { slug: string; admin?: { hidden?: boolean } } => {
76
+ if (!c?.slug) return false
77
+ return !c.admin?.hidden
78
+ })
79
+ .map((c) => c.slug)
80
+ return [...new Set(['pages', 'posts', ...fromConfig])]
81
+ }, [config])
82
+
83
+ const [rows, setRows] = useState<TrashRow[]>([])
84
+ const [loading, setLoading] = useState(true)
85
+ const [error, setError] = useState<string | null>(null)
86
+ const [restoringId, setRestoringId] = useState<string | null>(null)
87
+
88
+ const load = useCallback(async () => {
89
+ setLoading(true)
90
+ setError(null)
91
+ const results = await Promise.all(
92
+ collections.map(async (slug) => {
93
+ const res = await cmsApi<ListEnvelope>(
94
+ `/collections/${encodeURIComponent(slug)}?includeDeleted=true&pageSize=50&sort=updatedAt&order=desc`,
95
+ )
96
+ if (res.error) return [] as TrashRow[]
97
+ return (res.data?.docs ?? []).map((doc) => asRow(slug, doc))
98
+ }),
99
+ )
100
+ const merged = results.flat().filter((row) => row.id)
101
+ merged.sort((a, b) => {
102
+ const aTime = a.deletedAt ?? a.updatedAt ?? ''
103
+ const bTime = b.deletedAt ?? b.updatedAt ?? ''
104
+ return bTime.localeCompare(aTime)
105
+ })
106
+ setRows(merged)
107
+ setLoading(false)
108
+ }, [collections])
109
+
110
+ useEffect(() => {
111
+ void load()
112
+ }, [load])
113
+
114
+ async function handleRestore(row: TrashRow) {
115
+ setRestoringId(row.id)
116
+ const res = await cmsApi<{
117
+ document?: { id?: string }
118
+ slugConflict?: boolean
119
+ previousSlug?: string | null
120
+ }>(`/collections/${encodeURIComponent(row.collection)}/${encodeURIComponent(row.id)}/restore`, {
121
+ method: 'POST',
122
+ })
123
+ setRestoringId(null)
124
+ if (res.error) {
125
+ toast.error(res.error)
126
+ return
127
+ }
128
+ if (res.data?.slugConflict) {
129
+ toast.success(
130
+ `Restored "${row.title}". The slug "${res.data.previousSlug ?? row.slug}" is in use — set a new URL before publishing.`,
131
+ )
132
+ } else {
133
+ toast.success(`Restored "${row.title}"`)
134
+ }
135
+ onNavigate(editorPath(row.collection, row.id))
136
+ }
137
+
138
+ return (
139
+ <div className="p-4 pr-8">
140
+ <div className="mb-4">
141
+ <h1 className="text-foreground mb-1">Trash</h1>
142
+ <p className="text-muted-foreground text-sm">
143
+ Soft-deleted pages and posts stay here for 30 days, then are permanently removed.
144
+ </p>
145
+ </div>
146
+
147
+ {loading ? (
148
+ <div className="text-muted-foreground flex items-center gap-2 py-12 text-sm">
149
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
150
+ Loading trash…
151
+ </div>
152
+ ) : error ? (
153
+ <p className="text-destructive text-sm">{error}</p>
154
+ ) : rows.length === 0 ? (
155
+ <EmptyState
156
+ icon={<Trash2 size={24} />}
157
+ title="Trash is empty"
158
+ description="Deleted pages and posts will appear here so you can restore them."
159
+ />
160
+ ) : (
161
+ <div className="border-border overflow-x-auto rounded-lg border">
162
+ <table className="w-full min-w-[640px] text-sm" aria-label="Deleted documents">
163
+ <thead>
164
+ <tr className="text-muted-foreground border-border border-b text-left">
165
+ <th className="px-4 py-2.5 font-medium">Title</th>
166
+ <th className="px-4 py-2.5 font-medium">Collection</th>
167
+ <th className="px-4 py-2.5 font-medium">Slug</th>
168
+ <th className="px-4 py-2.5 font-medium">Deleted</th>
169
+ <th className="px-4 py-2.5 text-right font-medium">
170
+ <span className="sr-only">Actions</span>
171
+ </th>
172
+ </tr>
173
+ </thead>
174
+ <tbody className="divide-border divide-y">
175
+ {rows.map((row) => (
176
+ <tr key={`${row.collection}:${row.id}`}>
177
+ <td className="text-foreground px-4 py-2.5 font-medium">{row.title}</td>
178
+ <td className="text-muted-foreground px-4 py-2.5">{row.collection}</td>
179
+ <td className="text-muted-foreground px-4 py-2.5">{row.slug ?? '—'}</td>
180
+ <td className="text-muted-foreground px-4 py-2.5">
181
+ {formatWhen(row.deletedAt ?? row.updatedAt)}
182
+ </td>
183
+ <td className="px-4 py-2.5 text-right">
184
+ <button
185
+ type="button"
186
+ className="text-foreground hover:bg-accent focus-visible:ring-ring inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
187
+ onClick={() => void handleRestore(row)}
188
+ disabled={restoringId === row.id}
189
+ aria-label={`Restore ${row.title}`}
190
+ >
191
+ {restoringId === row.id ? (
192
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
193
+ ) : (
194
+ <RotateCcw className="h-4 w-4" aria-hidden />
195
+ )}
196
+ Restore
197
+ </button>
198
+ </td>
199
+ </tr>
200
+ ))}
201
+ </tbody>
202
+ </table>
203
+ </div>
204
+ )}
205
+ </div>
206
+ )
207
+ }
@@ -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
- canPublish: boolean
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 publish is blocked by validation (not role). */
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
- onPublish: () => void
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
- onPublish,
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 statusInfo = STATUS_TEXT[status]
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 publishBlocked = !canPublish || !publishReady || publishing || saveState === 'saving'
154
- const publishTitle = publishing
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
- : canPublish && !publishReady && publishDisabledReason
170
+ : !publishReady && publishDisabledReason
159
171
  ? publishDisabledReason
160
- : !canPublish
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
- : canPublish && !publishReady && publishDisabledReason
180
+ : !publishReady && publishDisabledReason
177
181
  ? publishDisabledReason
178
- : !canPublish
179
- ? 'You do not have permission to schedule'
180
- : scheduledAt
181
- ? `Reschedule — currently publishes ${formatScheduleLabel(scheduledAt)}`
182
- : 'Schedule a future publish date'
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 ${statusInfo.className}`}
203
- aria-label={`Status: ${statusInfo.label}`}
252
+ className={`ml-2 shrink-0 text-xs font-medium ${workflow.statusClassName}`}
253
+ aria-label={`Status: ${workflow.statusLabel}`}
204
254
  >
205
- {statusInfo.label}
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
- <button
369
- type="button"
370
- onClick={onPublish}
371
- disabled={publishBlocked}
372
- title={publishTitle}
373
- aria-disabled={publishBlocked}
374
- 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"
375
- >
376
- {publishing && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
377
- Publish
378
- </button>
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
  )