@prenta/admin 1.3.2 → 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 +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__/layout/sidebar-posts.test.js +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
- package/dist/__tests__/lib/preview-link.test.js +8 -0
- package/dist/__tests__/lib/preview-link.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/components/SEOConfigPanel.d.ts.map +1 -1
- package/dist/components/SEOConfigPanel.js +1 -1
- package/dist/components/SEOConfigPanel.js.map +1 -1
- package/dist/hooks/usePublicSiteUrl.d.ts +3 -0
- package/dist/hooks/usePublicSiteUrl.d.ts.map +1 -1
- package/dist/hooks/usePublicSiteUrl.js +3 -0
- package/dist/hooks/usePublicSiteUrl.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/lib/open-public-site-tab.d.ts +2 -0
- package/dist/lib/open-public-site-tab.d.ts.map +1 -1
- package/dist/lib/open-public-site-tab.js +1 -0
- package/dist/lib/open-public-site-tab.js.map +1 -1
- package/dist/lib/preview-link.d.ts +5 -2
- package/dist/lib/preview-link.d.ts.map +1 -1
- package/dist/lib/preview-link.js +7 -10
- package/dist/lib/preview-link.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/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -2
- 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 +3 -2
- 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/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
- package/dist/views/settings/SiteInformationCard.js +2 -1
- package/dist/views/settings/SiteInformationCard.js.map +1 -1
- package/dist/views/settings/useGeneralSettings.d.ts +1 -0
- package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
- package/dist/views/settings/useGeneralSettings.js +6 -1
- package/dist/views/settings/useGeneralSettings.js.map +1 -1
- package/package.json +5 -5
- package/src/AdminRoot.tsx +6 -0
- package/src/__tests__/layout/sidebar-posts.test.ts +1 -0
- package/src/__tests__/lib/preview-link.test.ts +11 -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/components/SEOConfigPanel.tsx +11 -0
- package/src/hooks/usePublicSiteUrl.ts +7 -0
- package/src/layout/Sidebar.tsx +3 -0
- package/src/lib/open-public-site-tab.ts +3 -0
- package/src/lib/preview-link.ts +24 -8
- 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/PageSectionEditor.tsx +7 -2
- package/src/views/post-editor/PostSectionEditor.tsx +7 -2
- package/src/views/seo/OverviewTab.tsx +1 -11
- package/src/views/settings/SiteInformationCard.tsx +23 -0
- package/src/views/settings/useGeneralSettings.ts +6 -1
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
Menu as MenuIcon,
|
|
32
32
|
PanelLeftClose,
|
|
33
33
|
PanelLeftOpen,
|
|
34
|
+
Trash2,
|
|
34
35
|
} from 'lucide-react'
|
|
35
36
|
import type { LucideIcon } from 'lucide-react'
|
|
36
37
|
import { PrentaBrandLogo } from '../assets/prenta-logo.js'
|
|
@@ -206,6 +207,7 @@ const defaultNavItems: NavItem[] = [
|
|
|
206
207
|
{ path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
|
|
207
208
|
{ path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
|
|
208
209
|
{ path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
|
|
210
|
+
{ path: '/trash', label: 'Trash', icon: Trash2, group: 'Content' },
|
|
209
211
|
{ path: '/settings', label: 'Settings', icon: Settings, group: 'Settings' },
|
|
210
212
|
]
|
|
211
213
|
|
|
@@ -825,6 +827,7 @@ export function buildNavItems(config: any): NavItem[] {
|
|
|
825
827
|
{ path: '/forms', label: 'Forms', icon: ClipboardList, group: 'Content' },
|
|
826
828
|
{ path: '/seo', label: 'SEO', icon: SearchIcon, group: 'Content' },
|
|
827
829
|
{ path: '/script-tags', label: 'Script Tags', icon: Code2, group: 'Content' },
|
|
830
|
+
{ path: '/trash', label: 'Trash', icon: Trash2, group: 'Content' },
|
|
828
831
|
)
|
|
829
832
|
|
|
830
833
|
// Plugin-contributed custom nav links (PluginDefinition.adminNavItems,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { toast } from 'sonner'
|
|
2
|
+
import type { TrailingSlashPolicy } from '@prenta/core/client'
|
|
2
3
|
import { buildPublicPreviewUrl, buildPublicUrl, createPreviewToken } from './preview-link.js'
|
|
3
4
|
import { NO_PUBLIC_SITE_URL_REASON, type ViewSiteIntent } from './view-site-chrome.js'
|
|
4
5
|
|
|
@@ -11,6 +12,7 @@ export async function openPublicSiteTab(opts: {
|
|
|
11
12
|
path?: string | null
|
|
12
13
|
urlPrefix?: string
|
|
13
14
|
data?: Record<string, unknown> | null
|
|
15
|
+
trailingSlash?: TrailingSlashPolicy | null
|
|
14
16
|
dirty: boolean
|
|
15
17
|
saveDraft: () => Promise<boolean>
|
|
16
18
|
}): Promise<void> {
|
|
@@ -23,6 +25,7 @@ export async function openPublicSiteTab(opts: {
|
|
|
23
25
|
slug: opts.slug,
|
|
24
26
|
path: opts.path,
|
|
25
27
|
data: opts.data,
|
|
28
|
+
trailingSlash: opts.trailingSlash,
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
if (opts.intent === 'view-live') {
|
package/src/lib/preview-link.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
documentPublicPath,
|
|
3
|
+
resolveDocumentUrl,
|
|
4
|
+
type TrailingSlashPolicy,
|
|
5
|
+
} from '@prenta/core/client'
|
|
2
6
|
import { cmsApi } from './api.js'
|
|
3
7
|
|
|
4
8
|
/**
|
|
@@ -40,6 +44,8 @@ export interface PublicUrlParts {
|
|
|
40
44
|
path?: string | null
|
|
41
45
|
/** Document `data` bag; `data.path` wins when `path` is omitted. */
|
|
42
46
|
data?: Record<string, unknown> | null
|
|
47
|
+
/** Must match `seo.trailingSlash` so View live opens the adapter URL. */
|
|
48
|
+
trailingSlash?: TrailingSlashPolicy | null
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface PublicPreviewUrlParts extends PublicUrlParts {
|
|
@@ -47,10 +53,12 @@ export interface PublicPreviewUrlParts extends PublicUrlParts {
|
|
|
47
53
|
token: string
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
function absolutePublicUrl(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
function absolutePublicUrl(
|
|
57
|
+
siteUrl: string,
|
|
58
|
+
path: string,
|
|
59
|
+
trailingSlash?: TrailingSlashPolicy | null,
|
|
60
|
+
): string {
|
|
61
|
+
return resolveDocumentUrl({ siteUrl, path, trailingSlash })
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/**
|
|
@@ -65,6 +73,7 @@ export function buildPublicPreviewUrl({
|
|
|
65
73
|
token,
|
|
66
74
|
path,
|
|
67
75
|
data,
|
|
76
|
+
trailingSlash,
|
|
68
77
|
}: PublicPreviewUrlParts): string {
|
|
69
78
|
const publicPath = documentPublicPath({ urlPrefix, slug, path, data })
|
|
70
79
|
// Home → `https://example.com/?preview=` (slash before query keeps relative
|
|
@@ -73,7 +82,7 @@ export function buildPublicPreviewUrl({
|
|
|
73
82
|
const root = (siteUrl ?? '').replace(/\/+$/, '')
|
|
74
83
|
return `${root || ''}/?preview=${encodeURIComponent(token)}`
|
|
75
84
|
}
|
|
76
|
-
return `${absolutePublicUrl(siteUrl, publicPath)}?preview=${encodeURIComponent(token)}`
|
|
85
|
+
return `${absolutePublicUrl(siteUrl, publicPath, trailingSlash)}?preview=${encodeURIComponent(token)}`
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
/**
|
|
@@ -81,8 +90,15 @@ export function buildPublicPreviewUrl({
|
|
|
81
90
|
* {@link buildPublicPreviewUrl} without a preview token. Home-style slugs and
|
|
82
91
|
* `data.path` are handled by {@link documentPublicPath}.
|
|
83
92
|
*/
|
|
84
|
-
export function buildPublicUrl({
|
|
85
|
-
|
|
93
|
+
export function buildPublicUrl({
|
|
94
|
+
siteUrl,
|
|
95
|
+
urlPrefix,
|
|
96
|
+
slug,
|
|
97
|
+
path,
|
|
98
|
+
data,
|
|
99
|
+
trailingSlash,
|
|
100
|
+
}: PublicUrlParts): string {
|
|
101
|
+
return resolveDocumentUrl({ siteUrl, urlPrefix, slug, path, data, trailingSlash })
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
/**
|
|
@@ -59,6 +59,7 @@ export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
|
|
|
59
59
|
'/script-tags/:id',
|
|
60
60
|
'/script-tags/new',
|
|
61
61
|
'/settings',
|
|
62
|
+
'/trash',
|
|
62
63
|
'/users',
|
|
63
64
|
'/widgets',
|
|
64
65
|
]
|
|
@@ -76,6 +77,7 @@ export const RESERVED_COLLECTION_SLUGS: ReadonlySet<string> = new Set([
|
|
|
76
77
|
'saved-sections',
|
|
77
78
|
'page-templates',
|
|
78
79
|
'widgets',
|
|
80
|
+
'trash',
|
|
79
81
|
'users',
|
|
80
82
|
'settings',
|
|
81
83
|
'profile',
|
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
|
+
}
|
|
@@ -218,7 +218,11 @@ export function PageSectionEditor({
|
|
|
218
218
|
|
|
219
219
|
const { canEdit, canPublish, canCreateRedirect } = useMemo(() => deriveCan(session), [session])
|
|
220
220
|
const customRenderers = useAdminSectionRenderers()
|
|
221
|
-
const {
|
|
221
|
+
const {
|
|
222
|
+
siteUrl: publicSiteUrl,
|
|
223
|
+
trailingSlash,
|
|
224
|
+
matchesAdminOrigin,
|
|
225
|
+
} = usePublicSiteUrl(config?.seo?.siteUrl)
|
|
222
226
|
|
|
223
227
|
// A misconfigured `pages` collection (missing the fields the editor
|
|
224
228
|
// writes) would silently drop content on save — detect it up front.
|
|
@@ -748,13 +752,14 @@ export function PageSectionEditor({
|
|
|
748
752
|
documentId: page.id,
|
|
749
753
|
slug: page.slug,
|
|
750
754
|
path: page.path,
|
|
755
|
+
trailingSlash,
|
|
751
756
|
dirty,
|
|
752
757
|
saveDraft: handleSaveDraft,
|
|
753
758
|
}).catch((err) => {
|
|
754
759
|
toast.error(err instanceof Error ? err.message : 'Failed to open the site')
|
|
755
760
|
})
|
|
756
761
|
},
|
|
757
|
-
[page, dirty, handleSaveDraft, publicSiteUrl],
|
|
762
|
+
[page, dirty, handleSaveDraft, publicSiteUrl, trailingSlash],
|
|
758
763
|
)
|
|
759
764
|
|
|
760
765
|
const handleShare = useCallback(async () => {
|
|
@@ -182,7 +182,11 @@ export function PostSectionEditor({
|
|
|
182
182
|
|
|
183
183
|
const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
|
|
184
184
|
const customRenderers = useAdminSectionRenderers()
|
|
185
|
-
const {
|
|
185
|
+
const {
|
|
186
|
+
siteUrl: publicSiteUrl,
|
|
187
|
+
trailingSlash,
|
|
188
|
+
matchesAdminOrigin,
|
|
189
|
+
} = usePublicSiteUrl(config?.seo?.siteUrl)
|
|
186
190
|
|
|
187
191
|
const typeLabel = useMemo(() => {
|
|
188
192
|
const col = config?.collections?.[postType]
|
|
@@ -610,13 +614,14 @@ export function PostSectionEditor({
|
|
|
610
614
|
slug: post.slug,
|
|
611
615
|
urlPrefix,
|
|
612
616
|
data: { path: (post as { path?: string }).path },
|
|
617
|
+
trailingSlash,
|
|
613
618
|
dirty,
|
|
614
619
|
saveDraft: handleSaveDraft,
|
|
615
620
|
}).catch((err) => {
|
|
616
621
|
toast.error(err instanceof Error ? err.message : 'Failed to open the site')
|
|
617
622
|
})
|
|
618
623
|
},
|
|
619
|
-
[post, postType, urlPrefix, dirty, handleSaveDraft, publicSiteUrl],
|
|
624
|
+
[post, postType, urlPrefix, dirty, handleSaveDraft, publicSiteUrl, trailingSlash],
|
|
620
625
|
)
|
|
621
626
|
|
|
622
627
|
const handleShare = useCallback(async () => {
|
|
@@ -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" />
|
|
@@ -33,6 +33,7 @@ export function SiteInformationCard({
|
|
|
33
33
|
const taglineId = useId()
|
|
34
34
|
const urlId = useId()
|
|
35
35
|
const urlErrId = useId()
|
|
36
|
+
const slashId = useId()
|
|
36
37
|
|
|
37
38
|
const titleIssue = issues.find((i) => i.field === 'siteTitle')
|
|
38
39
|
const urlIssue = issues.find((i) => i.field === 'siteUrl')
|
|
@@ -121,6 +122,28 @@ export function SiteInformationCard({
|
|
|
121
122
|
this URL.
|
|
122
123
|
</p>
|
|
123
124
|
</div>
|
|
125
|
+
|
|
126
|
+
<div>
|
|
127
|
+
<label htmlFor={slashId} className={LABEL_CLASS}>
|
|
128
|
+
Trailing slashes
|
|
129
|
+
</label>
|
|
130
|
+
<select
|
|
131
|
+
id={slashId}
|
|
132
|
+
value={form.trailingSlash}
|
|
133
|
+
disabled={!canEdit}
|
|
134
|
+
onChange={(e) =>
|
|
135
|
+
setField('trailingSlash', e.target.value === 'always' ? 'always' : 'never')
|
|
136
|
+
}
|
|
137
|
+
className={INPUT_CLASS}
|
|
138
|
+
>
|
|
139
|
+
<option value="never">Never — /about (Next.js default)</option>
|
|
140
|
+
<option value="always">Always — /about/ (Astro trailingSlash: always)</option>
|
|
141
|
+
</select>
|
|
142
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
143
|
+
Must match the public adapter. Drives sitemap, canonical / OG URLs, href rewrite on
|
|
144
|
+
save, and link-health.
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
124
147
|
</div>
|
|
125
148
|
</SettingsCard>
|
|
126
149
|
)
|
|
@@ -8,6 +8,7 @@ export interface GeneralSettingsForm {
|
|
|
8
8
|
siteTitle: string
|
|
9
9
|
tagline: string
|
|
10
10
|
siteUrl: string
|
|
11
|
+
trailingSlash: 'always' | 'never'
|
|
11
12
|
language: string
|
|
12
13
|
timezone: string
|
|
13
14
|
defaultNoIndex: boolean
|
|
@@ -59,6 +60,7 @@ const EMPTY_FORM: GeneralSettingsForm = {
|
|
|
59
60
|
siteTitle: '',
|
|
60
61
|
tagline: '',
|
|
61
62
|
siteUrl: '',
|
|
63
|
+
trailingSlash: 'never',
|
|
62
64
|
language: 'en',
|
|
63
65
|
timezone: 'UTC',
|
|
64
66
|
defaultNoIndex: false,
|
|
@@ -212,6 +214,7 @@ export function useGeneralSettings(canEdit: boolean): UseGeneralSettings {
|
|
|
212
214
|
siteTitle: String(settingsData.siteTitle ?? ''),
|
|
213
215
|
tagline: String(settingsData.tagline ?? ''),
|
|
214
216
|
siteUrl: String(effectiveSite.siteUrl ?? settingsData.siteUrl ?? ''),
|
|
217
|
+
trailingSlash: effectiveSite.trailingSlash === 'always' ? 'always' : 'never',
|
|
215
218
|
language: String(settingsData.language ?? 'en'),
|
|
216
219
|
timezone: String(settingsData.timezone ?? 'UTC'),
|
|
217
220
|
defaultNoIndex: robots.defaultNoIndex === true,
|
|
@@ -302,6 +305,7 @@ export function useGeneralSettings(canEdit: boolean): UseGeneralSettings {
|
|
|
302
305
|
form.language !== baseline.language ||
|
|
303
306
|
form.timezone !== baseline.timezone
|
|
304
307
|
const siteUrlChanged = siteUrlEditable && form.siteUrl !== baseline.siteUrl
|
|
308
|
+
const trailingSlashChanged = form.trailingSlash !== baseline.trailingSlash
|
|
305
309
|
const robotsChanged =
|
|
306
310
|
form.defaultNoIndex !== baseline.defaultNoIndex ||
|
|
307
311
|
form.defaultNoFollow !== baseline.defaultNoFollow ||
|
|
@@ -336,9 +340,10 @@ export function useGeneralSettings(canEdit: boolean): UseGeneralSettings {
|
|
|
336
340
|
|
|
337
341
|
// 2. Site URL + robots defaults → the SHARED SEO config (merge over the
|
|
338
342
|
// current override site so SEO-tab fields aren't lost).
|
|
339
|
-
if (siteUrlChanged || robotsChanged) {
|
|
343
|
+
if (siteUrlChanged || trailingSlashChanged || robotsChanged) {
|
|
340
344
|
const nextSite: Record<string, any> = { ...overrideSite }
|
|
341
345
|
if (siteUrlChanged) nextSite.siteUrl = form.siteUrl.trim() || undefined
|
|
346
|
+
if (trailingSlashChanged) nextSite.trailingSlash = form.trailingSlash
|
|
342
347
|
if (robotsChanged) {
|
|
343
348
|
nextSite.robots = {
|
|
344
349
|
...(overrideSite.robots ?? {}),
|