@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.
Files changed (86) 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__/layout/sidebar-posts.test.js +1 -0
  6. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  7. package/dist/__tests__/lib/preview-link.test.js +8 -0
  8. package/dist/__tests__/lib/preview-link.test.js.map +1 -1
  9. package/dist/__tests__/router/admin-routes.test.js +1 -0
  10. package/dist/__tests__/router/admin-routes.test.js.map +1 -1
  11. package/dist/__tests__/views/trash.render.test.d.ts +2 -0
  12. package/dist/__tests__/views/trash.render.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/trash.render.test.js +55 -0
  14. package/dist/__tests__/views/trash.render.test.js.map +1 -0
  15. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  16. package/dist/components/Breadcrumbs.js +1 -0
  17. package/dist/components/Breadcrumbs.js.map +1 -1
  18. package/dist/components/SEOConfigPanel.d.ts.map +1 -1
  19. package/dist/components/SEOConfigPanel.js +1 -1
  20. package/dist/components/SEOConfigPanel.js.map +1 -1
  21. package/dist/hooks/usePublicSiteUrl.d.ts +3 -0
  22. package/dist/hooks/usePublicSiteUrl.d.ts.map +1 -1
  23. package/dist/hooks/usePublicSiteUrl.js +3 -0
  24. package/dist/hooks/usePublicSiteUrl.js.map +1 -1
  25. package/dist/layout/Sidebar.d.ts.map +1 -1
  26. package/dist/layout/Sidebar.js +3 -2
  27. package/dist/layout/Sidebar.js.map +1 -1
  28. package/dist/lib/open-public-site-tab.d.ts +2 -0
  29. package/dist/lib/open-public-site-tab.d.ts.map +1 -1
  30. package/dist/lib/open-public-site-tab.js +1 -0
  31. package/dist/lib/open-public-site-tab.js.map +1 -1
  32. package/dist/lib/preview-link.d.ts +5 -2
  33. package/dist/lib/preview-link.d.ts.map +1 -1
  34. package/dist/lib/preview-link.js +7 -10
  35. package/dist/lib/preview-link.js.map +1 -1
  36. package/dist/prenta-admin.css +1 -1
  37. package/dist/router/admin-routes.d.ts.map +1 -1
  38. package/dist/router/admin-routes.js +2 -0
  39. package/dist/router/admin-routes.js.map +1 -1
  40. package/dist/views/ApiKeys.js +1 -5
  41. package/dist/views/ApiKeys.js.map +1 -1
  42. package/dist/views/SEO.d.ts.map +1 -1
  43. package/dist/views/SEO.js +1 -1
  44. package/dist/views/SEO.js.map +1 -1
  45. package/dist/views/Trash.d.ts +18 -0
  46. package/dist/views/Trash.d.ts.map +1 -0
  47. package/dist/views/Trash.js +104 -0
  48. package/dist/views/Trash.js.map +1 -0
  49. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  50. package/dist/views/page-editor/PageSectionEditor.js +3 -2
  51. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  52. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  53. package/dist/views/post-editor/PostSectionEditor.js +3 -2
  54. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  55. package/dist/views/seo/OverviewTab.d.ts +1 -2
  56. package/dist/views/seo/OverviewTab.d.ts.map +1 -1
  57. package/dist/views/seo/OverviewTab.js +3 -3
  58. package/dist/views/seo/OverviewTab.js.map +1 -1
  59. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
  60. package/dist/views/settings/SiteInformationCard.js +2 -1
  61. package/dist/views/settings/SiteInformationCard.js.map +1 -1
  62. package/dist/views/settings/useGeneralSettings.d.ts +1 -0
  63. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
  64. package/dist/views/settings/useGeneralSettings.js +6 -1
  65. package/dist/views/settings/useGeneralSettings.js.map +1 -1
  66. package/package.json +5 -5
  67. package/src/AdminRoot.tsx +6 -0
  68. package/src/__tests__/layout/sidebar-posts.test.ts +1 -0
  69. package/src/__tests__/lib/preview-link.test.ts +11 -0
  70. package/src/__tests__/router/admin-routes.test.ts +1 -0
  71. package/src/__tests__/views/trash.render.test.tsx +61 -0
  72. package/src/components/Breadcrumbs.tsx +1 -0
  73. package/src/components/SEOConfigPanel.tsx +11 -0
  74. package/src/hooks/usePublicSiteUrl.ts +7 -0
  75. package/src/layout/Sidebar.tsx +3 -0
  76. package/src/lib/open-public-site-tab.ts +3 -0
  77. package/src/lib/preview-link.ts +24 -8
  78. package/src/router/admin-routes.ts +2 -0
  79. package/src/views/ApiKeys.tsx +0 -13
  80. package/src/views/SEO.tsx +0 -5
  81. package/src/views/Trash.tsx +207 -0
  82. package/src/views/page-editor/PageSectionEditor.tsx +7 -2
  83. package/src/views/post-editor/PostSectionEditor.tsx +7 -2
  84. package/src/views/seo/OverviewTab.tsx +1 -11
  85. package/src/views/settings/SiteInformationCard.tsx +23 -0
  86. package/src/views/settings/useGeneralSettings.ts +6 -1
@@ -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') {
@@ -1,4 +1,8 @@
1
- import { documentPublicPath } from '@prenta/core/client'
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(siteUrl: string, path: string): string {
51
- const base = (siteUrl ?? '').replace(/\/+$/, '')
52
- if (path === '/') return base || '/'
53
- return `${base}${path}`
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({ siteUrl, urlPrefix, slug, path, data }: PublicUrlParts): string {
85
- return absolutePublicUrl(siteUrl, documentPublicPath({ urlPrefix, slug, path, data }))
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',
@@ -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 { siteUrl: publicSiteUrl, matchesAdminOrigin } = usePublicSiteUrl(config?.seo?.siteUrl)
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 { siteUrl: publicSiteUrl, matchesAdminOrigin } = usePublicSiteUrl(config?.seo?.siteUrl)
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="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" />
@@ -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 ?? {}),