@prenta/admin 1.4.0 → 1.5.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 +16 -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__/layout/sidebar-posts.test.js +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- 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/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/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -2
- package/dist/layout/Sidebar.js.map +1 -1
- 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/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__/layout/sidebar-posts.test.ts +1 -0
- package/src/__tests__/router/admin-routes.test.ts +1 -0
- package/src/__tests__/views/trash.render.test.tsx +61 -0
- package/src/components/Breadcrumbs.tsx +1 -0
- package/src/layout/Sidebar.tsx +3 -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/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
|
+
}
|
|
@@ -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" />
|