@prenta/admin 1.17.14 → 1.17.17

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 (73) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -25
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/lib/pages-service-fetch.test.js +3 -0
  6. package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -1
  7. package/dist/__tests__/views/admin-lazy-route.render.test.d.ts +2 -0
  8. package/dist/__tests__/views/admin-lazy-route.render.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/admin-lazy-route.render.test.js +30 -0
  10. package/dist/__tests__/views/admin-lazy-route.render.test.js.map +1 -0
  11. package/dist/__tests__/views/admin-lazy-views.test.d.ts +2 -0
  12. package/dist/__tests__/views/admin-lazy-views.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/admin-lazy-views.test.js +67 -0
  14. package/dist/__tests__/views/admin-lazy-views.test.js.map +1 -0
  15. package/dist/__tests__/views/audit-log-export.test.d.ts +2 -0
  16. package/dist/__tests__/views/audit-log-export.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/audit-log-export.test.js +11 -0
  18. package/dist/__tests__/views/audit-log-export.test.js.map +1 -0
  19. package/dist/__tests__/views/collection-list-paging.test.d.ts +2 -0
  20. package/dist/__tests__/views/collection-list-paging.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/collection-list-paging.test.js +101 -0
  22. package/dist/__tests__/views/collection-list-paging.test.js.map +1 -0
  23. package/dist/__tests__/views/trash.render.test.js +35 -0
  24. package/dist/__tests__/views/trash.render.test.js.map +1 -1
  25. package/dist/lib/pages-service.d.ts.map +1 -1
  26. package/dist/lib/pages-service.js +22 -2
  27. package/dist/lib/pages-service.js.map +1 -1
  28. package/dist/router/admin-routes.d.ts +2 -2
  29. package/dist/router/admin-routes.js +3 -3
  30. package/dist/router/admin-routes.js.map +1 -1
  31. package/dist/views/CollectionList.d.ts +13 -0
  32. package/dist/views/CollectionList.d.ts.map +1 -1
  33. package/dist/views/CollectionList.js +80 -22
  34. package/dist/views/CollectionList.js.map +1 -1
  35. package/dist/views/SEO.d.ts +1 -44
  36. package/dist/views/SEO.d.ts.map +1 -1
  37. package/dist/views/SEO.js +1 -46
  38. package/dist/views/SEO.js.map +1 -1
  39. package/dist/views/Trash.d.ts.map +1 -1
  40. package/dist/views/Trash.js +21 -2
  41. package/dist/views/Trash.js.map +1 -1
  42. package/dist/views/lazy-views.d.ts +30 -0
  43. package/dist/views/lazy-views.d.ts.map +1 -0
  44. package/dist/views/lazy-views.js +37 -0
  45. package/dist/views/lazy-views.js.map +1 -0
  46. package/dist/views/seo/seo-routes.d.ts +16 -0
  47. package/dist/views/seo/seo-routes.d.ts.map +1 -0
  48. package/dist/views/seo/seo-routes.js +53 -0
  49. package/dist/views/seo/seo-routes.js.map +1 -0
  50. package/dist/views/settings/AuditLogExportCard.d.ts +9 -0
  51. package/dist/views/settings/AuditLogExportCard.d.ts.map +1 -0
  52. package/dist/views/settings/AuditLogExportCard.js +66 -0
  53. package/dist/views/settings/AuditLogExportCard.js.map +1 -0
  54. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  55. package/dist/views/settings/SecurityTab.js +2 -1
  56. package/dist/views/settings/SecurityTab.js.map +1 -1
  57. package/package.json +4 -4
  58. package/src/AdminRoot.tsx +31 -25
  59. package/src/__tests__/lib/pages-service-fetch.test.ts +5 -0
  60. package/src/__tests__/views/admin-lazy-route.render.test.tsx +39 -0
  61. package/src/__tests__/views/admin-lazy-views.test.ts +70 -0
  62. package/src/__tests__/views/audit-log-export.test.ts +17 -0
  63. package/src/__tests__/views/collection-list-paging.test.tsx +112 -0
  64. package/src/__tests__/views/trash.render.test.tsx +40 -0
  65. package/src/lib/pages-service.ts +26 -2
  66. package/src/router/admin-routes.ts +3 -3
  67. package/src/views/CollectionList.tsx +121 -40
  68. package/src/views/SEO.tsx +1 -48
  69. package/src/views/Trash.tsx +68 -16
  70. package/src/views/lazy-views.tsx +79 -0
  71. package/src/views/seo/seo-routes.ts +58 -0
  72. package/src/views/settings/AuditLogExportCard.tsx +131 -0
  73. package/src/views/settings/SecurityTab.tsx +3 -1
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ const srcRoot = fileURLToPath(new URL('../../', import.meta.url))
7
+
8
+ const HEAVY_VIEWS = [
9
+ 'views/page-editor/PageSectionEditor.tsx',
10
+ 'views/post-editor/PostSectionEditor.tsx',
11
+ 'views/post-editor/PostTemplateEditor.tsx',
12
+ 'views/Settings.tsx',
13
+ 'views/SEO.tsx',
14
+ 'views/MediaBrowser.tsx',
15
+ 'views/DocumentEdit.tsx',
16
+ 'views/Forms.tsx',
17
+ ]
18
+
19
+ function resolveSpecifier(fromFile: string, specifier: string): string | undefined {
20
+ const base = resolve(dirname(fromFile), specifier)
21
+ const candidates = [
22
+ base,
23
+ base.replace(/\.js$/, '.tsx'),
24
+ base.replace(/\.js$/, '.ts'),
25
+ `${base}.tsx`,
26
+ `${base}.ts`,
27
+ join(base, 'index.tsx'),
28
+ join(base, 'index.ts'),
29
+ ]
30
+ return candidates.find((candidate) => existsSync(candidate))
31
+ }
32
+
33
+ /** Static import graph. Dynamic `import()` edges are stripped first. */
34
+ function staticGraph(entry: string): Set<string> {
35
+ const seen = new Set<string>()
36
+ const stack = [entry]
37
+ while (stack.length > 0) {
38
+ const file = stack.pop()
39
+ if (!file || seen.has(file)) continue
40
+ seen.add(file)
41
+ const text = readFileSync(file, 'utf8').replace(/import\s*\(\s*['"][^'"]+['"]\s*\)/g, '')
42
+ for (const match of text.matchAll(/from ['"](\.[^'"]+)['"]/g)) {
43
+ const specifier = match[1]
44
+ if (!specifier) continue
45
+ const next = resolveSpecifier(file, specifier)
46
+ if (next) stack.push(next)
47
+ }
48
+ }
49
+ return seen
50
+ }
51
+
52
+ describe('admin route-level code splitting', () => {
53
+ it('keeps heavy views out of the AdminRoot static import graph', () => {
54
+ const graph = staticGraph(join(srcRoot, 'AdminRoot.tsx'))
55
+ const present = HEAVY_VIEWS.filter((view) => graph.has(join(srcRoot, view)))
56
+ expect(present).toEqual([])
57
+
58
+ const bytes = HEAVY_VIEWS.reduce((total, view) => total + statSync(join(srcRoot, view)).size, 0)
59
+ // The shell no longer statically imports these modules. Their source is
60
+ // the chunk the app bundler can defer. This floor fails if the split is
61
+ // reverted and the files are pulled back into the entry graph above.
62
+ expect(bytes).toBeGreaterThan(40_000)
63
+
64
+ const lazySource = readFileSync(join(srcRoot, 'views/lazy-views.tsx'), 'utf8')
65
+ expect(lazySource).toContain("import('./page-editor/PageSectionEditor.js')")
66
+ expect(lazySource).toContain("import('./SEO.js')")
67
+ expect(lazySource).toContain("import('./Settings.js')")
68
+ expect(lazySource).toContain("import('./MediaBrowser.js')")
69
+ })
70
+ })
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { auditExportPath } from '../../views/settings/AuditLogExportCard.js'
4
+
5
+ describe('auditExportPath', () => {
6
+ it('omits empty dates and keeps the format', () => {
7
+ expect(auditExportPath({ format: 'csv', from: '', to: '' })).toBe(
8
+ '/security/audit-log/export?format=csv',
9
+ )
10
+ })
11
+
12
+ it('includes a date range', () => {
13
+ expect(auditExportPath({ format: 'json', from: '2026-01-01', to: '2026-01-31' })).toBe(
14
+ '/security/audit-log/export?format=json&from=2026-01-01&to=2026-01-31',
15
+ )
16
+ })
17
+ })
@@ -0,0 +1,112 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import { buildCollectionListUrl } from '../../views/CollectionList.js'
5
+
6
+ /**
7
+ * Collection list pages with the keyset cursor from `listDocuments`, not
8
+ * OFFSET `page`. Deep pages of a large collection must not send `skip`.
9
+ */
10
+
11
+ const seen: string[] = []
12
+
13
+ vi.mock('../../lib/api.js', () => ({
14
+ cmsApi: vi.fn(async () => ({ data: {}, status: 200 })),
15
+ ensureCsrfToken: vi.fn(async () => {}),
16
+ }))
17
+
18
+ vi.mock('../../lib/useApiData.js', () => ({
19
+ useApiData: (endpoint: string) => {
20
+ seen.push(endpoint)
21
+ const cursor = new URL(`http://localhost${endpoint}`).searchParams.get('cursor')
22
+ return {
23
+ data: {
24
+ docs: [
25
+ {
26
+ id: '1',
27
+ title: 'Hello',
28
+ status: 'DRAFT',
29
+ updatedAt: '2026-01-01T00:00:00Z',
30
+ data: { title: 'Hello' },
31
+ },
32
+ ],
33
+ total: 60,
34
+ nextCursor: cursor ? null : 'cursor-2',
35
+ },
36
+ loading: false,
37
+ error: null,
38
+ refetch: vi.fn(),
39
+ }
40
+ },
41
+ }))
42
+
43
+ const CONFIG = {
44
+ collections: {
45
+ posts: {
46
+ slug: 'posts',
47
+ labels: { singular: 'Post', plural: 'Posts' },
48
+ fields: { title: { type: 'text', label: 'Title' } },
49
+ },
50
+ },
51
+ }
52
+
53
+ beforeEach(() => {
54
+ seen.length = 0
55
+ })
56
+
57
+ describe('buildCollectionListUrl', () => {
58
+ it('omits OFFSET page and sends the keyset cursor', () => {
59
+ const first = buildCollectionListUrl({
60
+ collectionSlug: 'posts',
61
+ pick: 'title,slug',
62
+ cursor: null,
63
+ search: '',
64
+ sort: null,
65
+ order: 'asc',
66
+ filters: {},
67
+ })
68
+ const params = new URL(`http://localhost${first}`).searchParams
69
+ expect(params.get('page')).toBeNull()
70
+ expect(params.get('pageSize')).toBe('25')
71
+ expect(params.get('cursor')).toBeNull()
72
+ expect(params.get('pick')).toBe('title,slug')
73
+
74
+ const next = buildCollectionListUrl({
75
+ collectionSlug: 'posts',
76
+ pick: 'title,slug',
77
+ cursor: 'doc-25',
78
+ search: 'hello',
79
+ sort: 'updatedAt',
80
+ order: 'desc',
81
+ filters: { status: 'PUBLISHED' },
82
+ })
83
+ const nextParams = new URL(`http://localhost${next}`).searchParams
84
+ expect(nextParams.get('page')).toBeNull()
85
+ expect(nextParams.get('cursor')).toBe('doc-25')
86
+ expect(nextParams.get('search')).toBe('hello')
87
+ expect(nextParams.get('sort')).toBe('updatedAt')
88
+ expect(nextParams.get('order')).toBe('desc')
89
+ expect(nextParams.get('filter.status')).toBe('PUBLISHED')
90
+ })
91
+ })
92
+
93
+ describe('CollectionList paging', () => {
94
+ it('requests the next page with nextCursor and returns without an offset', async () => {
95
+ const { CollectionList } = await import('../../views/CollectionList.js')
96
+ render(<CollectionList collectionSlug="posts" config={CONFIG} onNavigate={vi.fn()} />)
97
+
98
+ const first = seen[seen.length - 1] ?? ''
99
+ expect(new URL(`http://localhost${first}`).searchParams.get('page')).toBeNull()
100
+
101
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
102
+
103
+ const afterNext = seen[seen.length - 1] ?? ''
104
+ expect(new URL(`http://localhost${afterNext}`).searchParams.get('cursor')).toBe('cursor-2')
105
+ expect(screen.getByText('Page 2 of 3')).toBeTruthy()
106
+
107
+ fireEvent.click(screen.getByRole('button', { name: 'Previous' }))
108
+ const afterPrev = seen[seen.length - 1] ?? ''
109
+ expect(new URL(`http://localhost${afterPrev}`).searchParams.get('cursor')).toBeNull()
110
+ expect(screen.getByText('Page 1 of 3')).toBeTruthy()
111
+ })
112
+ })
@@ -96,4 +96,44 @@ describe('Trash', () => {
96
96
  )
97
97
  })
98
98
  })
99
+
100
+ it('asks before permanently deleting a trashed document', async () => {
101
+ cmsApi.mockImplementation(async (endpoint: string, options?: { method?: string }) => {
102
+ if (options?.method === 'POST' && String(endpoint).includes('/purge')) {
103
+ return { data: { success: true }, status: 200 }
104
+ }
105
+ if (String(endpoint).includes('/collections/pages')) {
106
+ return {
107
+ data: {
108
+ docs: [
109
+ {
110
+ id: 'p1',
111
+ title: 'About',
112
+ slug: null,
113
+ deletedAt: '2026-08-01T00:00:00.000Z',
114
+ data: { slug: 'about' },
115
+ },
116
+ ],
117
+ },
118
+ status: 200,
119
+ }
120
+ }
121
+ return { data: { docs: [] }, status: 200 }
122
+ })
123
+
124
+ render(<Trash onNavigate={vi.fn()} />)
125
+ expect(await screen.findByText('About')).toBeTruthy()
126
+ fireEvent.click(screen.getByRole('button', { name: 'Delete About permanently' }))
127
+ expect(cmsApi).not.toHaveBeenCalledWith(expect.stringContaining('/purge'), expect.anything())
128
+ fireEvent.click(screen.getByRole('button', { name: 'Delete permanently' }))
129
+ await waitFor(() => {
130
+ expect(cmsApi).toHaveBeenCalledWith(
131
+ '/collections/pages/p1/purge',
132
+ expect.objectContaining({ method: 'POST' }),
133
+ )
134
+ })
135
+ await waitFor(() => {
136
+ expect(screen.queryByText('About')).toBeNull()
137
+ })
138
+ })
99
139
  })
@@ -33,6 +33,28 @@ export type PageStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
33
33
  const PAGES_COLLECTION = 'pages'
34
34
  const PAGE_TAGS_COLLECTION = 'page-tags'
35
35
 
36
+ /** Keys the pages table reads. `sections` and other content blobs stay off the wire. */
37
+ const PAGE_LIST_PICK = [
38
+ 'title',
39
+ 'slug',
40
+ 'path',
41
+ 'parentPageId',
42
+ 'tags',
43
+ 'primaryTag',
44
+ 'seoScore',
45
+ 'seo_score',
46
+ 'metaScore',
47
+ 'metaTitle',
48
+ 'seoTitle',
49
+ 'metaDescription',
50
+ 'seoDescription',
51
+ 'templateId',
52
+ 'isHomepage',
53
+ 'isSystemPage',
54
+ ].join(',')
55
+
56
+ const TAG_LIST_PICK = ['title', 'slug', 'description', 'color', 'sortOrder', 'active'].join(',')
57
+
36
58
  export interface PageTag {
37
59
  id: string // == slug (stable join key; pages store slugs in data.tags)
38
60
  docId: string // underlying document id — required for tag CRUD
@@ -217,7 +239,7 @@ function docToTag(d: DocumentApiRow, pageCount: number): PageTag {
217
239
 
218
240
  async function fetchTagDocs(): Promise<DocumentApiRow[]> {
219
241
  const res = await cmsApi<ListEnvelope>(
220
- `/collections/${PAGE_TAGS_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`,
242
+ `/collections/${PAGE_TAGS_COLLECTION}?pageSize=500&sort=updatedAt&order=desc&pick=${TAG_LIST_PICK}`,
221
243
  )
222
244
  return (res.data?.docs ?? []) as DocumentApiRow[]
223
245
  }
@@ -366,7 +388,9 @@ export async function fetchPagesDataset(): Promise<PagesDataset> {
366
388
  const authorsMap = await fetchAuthorsMap()
367
389
 
368
390
  const [pagesRes, tagDocs] = await Promise.all([
369
- cmsApi<ListEnvelope>(`/collections/${PAGES_COLLECTION}?pageSize=500&sort=updatedAt&order=desc`),
391
+ cmsApi<ListEnvelope>(
392
+ `/collections/${PAGES_COLLECTION}?pageSize=500&sort=updatedAt&order=desc&pick=${PAGE_LIST_PICK}`,
393
+ ),
370
394
  fetchTagDocs(),
371
395
  ])
372
396
 
@@ -12,13 +12,13 @@
12
12
  * the `admin-routes` drift test keeps `ADMIN_STATIC_ROUTE_PATTERNS` in sync with
13
13
  * the literal `matchRoute('…')` calls in AdminRoot.
14
14
  */
15
- import { SEO_DEEP_PATHS } from '../views/SEO.js'
15
+ import { SEO_DEEP_PATHS } from '../views/seo/seo-routes.js'
16
16
 
17
17
  /**
18
18
  * Every static route pattern `AdminRoot.renderView()` resolves via a literal
19
19
  * `matchRoute('…')` call. Dynamic collection routes are derived per-config in
20
- * {@link collectionRoutePatterns}; the SEO deep paths are added from the view's
21
- * own {@link SEO_DEEP_PATHS} so the two can't drift.
20
+ * {@link collectionRoutePatterns}; the SEO deep paths are added from
21
+ * {@link SEO_DEEP_PATHS} so the two can't drift.
22
22
  */
23
23
  export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
24
24
  '/',
@@ -102,6 +102,52 @@ function facetCellValue(doc: any, facet: FacetDef): string {
102
102
  return ''
103
103
  }
104
104
 
105
+ const SKIPPED_LIST_FIELDS = new Set(['richText', 'blocks', 'json', 'media', 'relationship'])
106
+
107
+ /** Scalar field names the table and inspector show. Content blobs are not requested. */
108
+ const LIST_PAGE_SIZE = 25
109
+
110
+ /** Collection list query. Keyset `cursor` replaces OFFSET `page` (#445). */
111
+ export function buildCollectionListUrl(input: {
112
+ collectionSlug: string
113
+ pick: string
114
+ cursor: string | null
115
+ search: string
116
+ sort: SortField | null
117
+ order: SortOrder
118
+ filters: Record<string, string>
119
+ }): string {
120
+ const params = new URLSearchParams()
121
+ params.set('pageSize', String(LIST_PAGE_SIZE))
122
+ params.set('pick', input.pick)
123
+ if (input.cursor) params.set('cursor', input.cursor)
124
+ if (input.search) params.set('search', input.search)
125
+ if (input.sort) {
126
+ params.set('sort', input.sort)
127
+ params.set('order', input.order)
128
+ }
129
+ for (const [field, value] of Object.entries(input.filters)) {
130
+ if (value) params.set(`filter.${field}`, value)
131
+ }
132
+ return `/collections/${input.collectionSlug}?${params.toString()}`
133
+ }
134
+
135
+ function listPickParam(config: any, slug: string, facets: FacetDef[]): string {
136
+ const names = new Set<string>(['title', 'slug'])
137
+ const fields = resolveCollection(config, slug)?.fields
138
+ if (fields && typeof fields === 'object') {
139
+ for (const [name, field] of Object.entries(fields) as Array<[string, any]>) {
140
+ if (!field || field.hidden) continue
141
+ if (typeof field.type === 'string' && SKIPPED_LIST_FIELDS.has(field.type)) continue
142
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) names.add(name)
143
+ }
144
+ }
145
+ for (const facet of facets) {
146
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(facet.name)) names.add(facet.name)
147
+ }
148
+ return [...names].slice(0, 32).join(',')
149
+ }
150
+
105
151
  function statusColor(status: string): string {
106
152
  switch (status?.toUpperCase()) {
107
153
  case 'PUBLISHED':
@@ -160,7 +206,8 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
160
206
  [collectionDef, collectionSlug],
161
207
  )
162
208
 
163
- const [page, setPage] = useState(1)
209
+ const [cursor, setCursor] = useState<string | null>(null)
210
+ const [priorCursors, setPriorCursors] = useState<Array<string | null>>([])
164
211
  const [search, setSearch] = useState('')
165
212
  const [debouncedSearch, setDebouncedSearch] = useState('')
166
213
  const [sort, setSort] = useState<SortField | null>(null)
@@ -173,38 +220,53 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
173
220
  const [facetValues, setFacetValues] = useState<Record<string, string[]>>({})
174
221
  const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
175
222
 
223
+ const resetCursor = useCallback(() => {
224
+ setCursor(null)
225
+ setPriorCursors([])
226
+ }, [])
227
+
176
228
  useEffect(() => {
177
229
  clearTimeout(searchTimer.current)
178
230
  searchTimer.current = setTimeout(() => {
179
231
  setDebouncedSearch(search)
180
- setPage(1)
232
+ resetCursor()
181
233
  }, 300)
182
234
  return () => clearTimeout(searchTimer.current)
183
- }, [search])
184
-
185
- const endpoint = useMemo(() => {
186
- let url = `/collections/${collectionSlug}?page=${page}&pageSize=25`
187
- if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`
188
- if (sort) url += `&sort=${sort}&order=${order}`
189
- for (const [field, value] of Object.entries(filters)) {
190
- if (value) url += `&filter.${encodeURIComponent(field)}=${encodeURIComponent(value)}`
191
- }
192
- return url
193
- }, [collectionSlug, page, debouncedSearch, sort, order, filters])
235
+ }, [search, resetCursor])
236
+
237
+ const endpoint = useMemo(
238
+ () =>
239
+ buildCollectionListUrl({
240
+ collectionSlug,
241
+ pick: listPickParam(config, collectionSlug, facets),
242
+ cursor,
243
+ search: debouncedSearch,
244
+ sort,
245
+ order,
246
+ filters,
247
+ }),
248
+ [collectionSlug, cursor, debouncedSearch, sort, order, filters, config, facets],
249
+ )
194
250
 
195
- const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(endpoint)
251
+ const { data, loading, error, refetch } = useApiData<{
252
+ docs: any[]
253
+ total: number
254
+ nextCursor?: string | null
255
+ }>(endpoint)
196
256
  const docs = data?.docs ?? []
197
257
  const total = data?.total ?? 0
198
- const totalPages = Math.max(1, Math.ceil(total / 25))
258
+ const totalPages = Math.max(1, Math.ceil(total / LIST_PAGE_SIZE))
259
+ const pageNumber = priorCursors.length + 1
260
+ const nextCursor = data?.nextCursor ?? null
199
261
 
200
262
  useEffect(() => {
201
- setPage(1)
263
+ resetCursor()
202
264
  setSelected(new Set())
203
265
  setDetailId(null)
204
266
  setSearch('')
205
267
  setFilters({})
206
268
  setFacetValues({})
207
- }, [collectionSlug])
269
+ }, [collectionSlug, resetCursor])
208
270
 
209
271
  // Load in-use values for facets that aren't config-defined selects.
210
272
  useEffect(() => {
@@ -226,26 +288,33 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
226
288
  }
227
289
  }, [collectionSlug, facets])
228
290
 
229
- const setFilter = useCallback((field: string, value: string) => {
230
- setFilters((prev) => {
231
- const next = { ...prev }
232
- if (value) next[field] = value
233
- else delete next[field]
234
- return next
235
- })
236
- setPage(1)
237
- }, [])
291
+ const setFilter = useCallback(
292
+ (field: string, value: string) => {
293
+ setFilters((prev) => {
294
+ const next = { ...prev }
295
+ if (value) next[field] = value
296
+ else delete next[field]
297
+ return next
298
+ })
299
+ resetCursor()
300
+ },
301
+ [resetCursor],
302
+ )
238
303
 
239
- const toggleSort = useCallback((field: SortField) => {
240
- setSort((prev) => {
241
- if (prev === field) {
242
- setOrder((o) => (o === 'asc' ? 'desc' : 'asc'))
304
+ const toggleSort = useCallback(
305
+ (field: SortField) => {
306
+ resetCursor()
307
+ setSort((prev) => {
308
+ if (prev === field) {
309
+ setOrder((o) => (o === 'asc' ? 'desc' : 'asc'))
310
+ return field
311
+ }
312
+ setOrder('asc')
243
313
  return field
244
- }
245
- setOrder('asc')
246
- return field
247
- })
248
- }, [])
314
+ })
315
+ },
316
+ [resetCursor],
317
+ )
249
318
 
250
319
  const detailDoc = useMemo(
251
320
  () => (detailId ? (docs.find((d: any) => String(d.id) === detailId) ?? null) : null),
@@ -631,20 +700,32 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
631
700
  style={{ color: 'var(--prenta-text-secondary, #6b7280)' }}
632
701
  >
633
702
  <span>
634
- Page {page} of {totalPages}
703
+ Page {pageNumber} of {totalPages}
635
704
  </span>
636
705
  <div className="flex gap-2">
637
706
  <button
638
- disabled={page <= 1}
639
- onClick={() => setPage((p) => p - 1)}
707
+ type="button"
708
+ disabled={priorCursors.length === 0}
709
+ onClick={() => {
710
+ setPriorCursors((stack) => {
711
+ if (stack.length === 0) return stack
712
+ setCursor(stack[stack.length - 1] ?? null)
713
+ return stack.slice(0, -1)
714
+ })
715
+ }}
640
716
  className="flex items-center gap-1 rounded-lg border px-3 py-1.5 transition-opacity disabled:opacity-40"
641
717
  style={{ borderColor: 'var(--prenta-border, #d1d5db)' }}
642
718
  >
643
719
  <ChevronLeft className="h-4 w-4" /> Previous
644
720
  </button>
645
721
  <button
646
- disabled={page >= totalPages}
647
- onClick={() => setPage((p) => p + 1)}
722
+ type="button"
723
+ disabled={!nextCursor}
724
+ onClick={() => {
725
+ if (!nextCursor) return
726
+ setPriorCursors((stack) => [...stack, cursor])
727
+ setCursor(nextCursor)
728
+ }}
648
729
  className="flex items-center gap-1 rounded-lg border px-3 py-1.5 transition-opacity disabled:opacity-40"
649
730
  style={{ borderColor: 'var(--prenta-border, #d1d5db)' }}
650
731
  >
package/src/views/SEO.tsx CHANGED
@@ -37,6 +37,7 @@ import { AuditTab } from './seo/AuditTab.js'
37
37
  import { LinksTab } from './seo/LinksTab.js'
38
38
  import { ProposalsTab } from './seo/ProposalsTab.js'
39
39
  import { AiVisibilityTab } from './seo/AiVisibilityTab.js'
40
+ export { SEO_DEEP_PATHS, seoTabForPath } from './seo/seo-routes.js'
40
41
 
41
42
  export interface SEOProps {
42
43
  onNavigate?: (path: string) => void
@@ -61,54 +62,6 @@ function normalizeTab(tab: string | undefined): TabId {
61
62
  return (valid.includes(tab ?? '') ? tab : 'overview') as TabId
62
63
  }
63
64
 
64
- /**
65
- * SEO deep paths the admin router resolves onto the tabbed SEO view — including
66
- * every path plugin-seo advertises in the sidebar. `matchRoute` is exact, so any
67
- * of these NOT listed here falls through to the generic 404 (which is exactly
68
- * how `/seo/link-health` and `/seo/sitemap` used to break).
69
- */
70
- export const SEO_DEEP_PATHS = [
71
- '/seo/link-health',
72
- '/seo/sitemap',
73
- '/seo/redirects',
74
- '/seo/content',
75
- '/seo/canonicals',
76
- '/seo/technical',
77
- '/seo/links',
78
- '/seo/audit',
79
- '/seo/proposals',
80
- '/seo/ai-visibility',
81
- '/seo',
82
- ] as const
83
-
84
- /** Map an SEO deep path to the tab it should open, or null if it isn't one. */
85
- export function seoTabForPath(pathname: string): TabId | null {
86
- switch (pathname) {
87
- case '/seo/link-health':
88
- return 'links'
89
- case '/seo/sitemap':
90
- return 'technical'
91
- case '/seo/redirects':
92
- return 'redirects'
93
- case '/seo/content':
94
- case '/seo/canonicals':
95
- return 'content'
96
- case '/seo/technical':
97
- case '/seo/links':
98
- return 'technical'
99
- case '/seo/audit':
100
- return 'audit'
101
- case '/seo/proposals':
102
- return 'proposals'
103
- case '/seo/ai-visibility':
104
- return 'ai-visibility'
105
- case '/seo':
106
- return 'overview'
107
- default:
108
- return null
109
- }
110
- }
111
-
112
65
  interface UrlState {
113
66
  tab: string | null
114
67
  entityType: string | null