@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
package/src/views/ApiKeys.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
)
|