@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +16 -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__/layout/sidebar-posts.test.js +1 -0
  6. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  7. package/dist/__tests__/router/admin-routes.test.js +1 -0
  8. package/dist/__tests__/router/admin-routes.test.js.map +1 -1
  9. package/dist/__tests__/views/trash.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/trash.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/trash.render.test.js +55 -0
  12. package/dist/__tests__/views/trash.render.test.js.map +1 -0
  13. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  14. package/dist/components/Breadcrumbs.js +1 -0
  15. package/dist/components/Breadcrumbs.js.map +1 -1
  16. package/dist/layout/Sidebar.d.ts.map +1 -1
  17. package/dist/layout/Sidebar.js +3 -2
  18. package/dist/layout/Sidebar.js.map +1 -1
  19. package/dist/prenta-admin.css +1 -1
  20. package/dist/router/admin-routes.d.ts.map +1 -1
  21. package/dist/router/admin-routes.js +2 -0
  22. package/dist/router/admin-routes.js.map +1 -1
  23. package/dist/views/ApiKeys.js +1 -5
  24. package/dist/views/ApiKeys.js.map +1 -1
  25. package/dist/views/SEO.d.ts.map +1 -1
  26. package/dist/views/SEO.js +1 -1
  27. package/dist/views/SEO.js.map +1 -1
  28. package/dist/views/Trash.d.ts +18 -0
  29. package/dist/views/Trash.d.ts.map +1 -0
  30. package/dist/views/Trash.js +104 -0
  31. package/dist/views/Trash.js.map +1 -0
  32. package/dist/views/seo/OverviewTab.d.ts +1 -2
  33. package/dist/views/seo/OverviewTab.d.ts.map +1 -1
  34. package/dist/views/seo/OverviewTab.js +3 -3
  35. package/dist/views/seo/OverviewTab.js.map +1 -1
  36. package/package.json +4 -4
  37. package/src/AdminRoot.tsx +6 -0
  38. package/src/__tests__/layout/sidebar-posts.test.ts +1 -0
  39. package/src/__tests__/router/admin-routes.test.ts +1 -0
  40. package/src/__tests__/views/trash.render.test.tsx +61 -0
  41. package/src/components/Breadcrumbs.tsx +1 -0
  42. package/src/layout/Sidebar.tsx +3 -0
  43. package/src/router/admin-routes.ts +2 -0
  44. package/src/views/ApiKeys.tsx +0 -13
  45. package/src/views/SEO.tsx +0 -5
  46. package/src/views/Trash.tsx +207 -0
  47. 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
+ }
@@ -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="Connect Google Search Console to see real impressions, clicks, CTR, and average position."
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" />