@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.
- package/CHANGELOG.md +20 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -25
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/pages-service-fetch.test.js +3 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -1
- package/dist/__tests__/views/admin-lazy-route.render.test.d.ts +2 -0
- package/dist/__tests__/views/admin-lazy-route.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/admin-lazy-route.render.test.js +30 -0
- package/dist/__tests__/views/admin-lazy-route.render.test.js.map +1 -0
- package/dist/__tests__/views/admin-lazy-views.test.d.ts +2 -0
- package/dist/__tests__/views/admin-lazy-views.test.d.ts.map +1 -0
- package/dist/__tests__/views/admin-lazy-views.test.js +67 -0
- package/dist/__tests__/views/admin-lazy-views.test.js.map +1 -0
- package/dist/__tests__/views/audit-log-export.test.d.ts +2 -0
- package/dist/__tests__/views/audit-log-export.test.d.ts.map +1 -0
- package/dist/__tests__/views/audit-log-export.test.js +11 -0
- package/dist/__tests__/views/audit-log-export.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-paging.test.d.ts +2 -0
- package/dist/__tests__/views/collection-list-paging.test.d.ts.map +1 -0
- package/dist/__tests__/views/collection-list-paging.test.js +101 -0
- package/dist/__tests__/views/collection-list-paging.test.js.map +1 -0
- package/dist/__tests__/views/trash.render.test.js +35 -0
- package/dist/__tests__/views/trash.render.test.js.map +1 -1
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +22 -2
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/router/admin-routes.d.ts +2 -2
- package/dist/router/admin-routes.js +3 -3
- package/dist/router/admin-routes.js.map +1 -1
- package/dist/views/CollectionList.d.ts +13 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +80 -22
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/SEO.d.ts +1 -44
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +1 -46
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Trash.d.ts.map +1 -1
- package/dist/views/Trash.js +21 -2
- package/dist/views/Trash.js.map +1 -1
- package/dist/views/lazy-views.d.ts +30 -0
- package/dist/views/lazy-views.d.ts.map +1 -0
- package/dist/views/lazy-views.js +37 -0
- package/dist/views/lazy-views.js.map +1 -0
- package/dist/views/seo/seo-routes.d.ts +16 -0
- package/dist/views/seo/seo-routes.d.ts.map +1 -0
- package/dist/views/seo/seo-routes.js +53 -0
- package/dist/views/seo/seo-routes.js.map +1 -0
- package/dist/views/settings/AuditLogExportCard.d.ts +9 -0
- package/dist/views/settings/AuditLogExportCard.d.ts.map +1 -0
- package/dist/views/settings/AuditLogExportCard.js +66 -0
- package/dist/views/settings/AuditLogExportCard.js.map +1 -0
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +2 -1
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/package.json +4 -4
- package/src/AdminRoot.tsx +31 -25
- package/src/__tests__/lib/pages-service-fetch.test.ts +5 -0
- package/src/__tests__/views/admin-lazy-route.render.test.tsx +39 -0
- package/src/__tests__/views/admin-lazy-views.test.ts +70 -0
- package/src/__tests__/views/audit-log-export.test.ts +17 -0
- package/src/__tests__/views/collection-list-paging.test.tsx +112 -0
- package/src/__tests__/views/trash.render.test.tsx +40 -0
- package/src/lib/pages-service.ts +26 -2
- package/src/router/admin-routes.ts +3 -3
- package/src/views/CollectionList.tsx +121 -40
- package/src/views/SEO.tsx +1 -48
- package/src/views/Trash.tsx +68 -16
- package/src/views/lazy-views.tsx +79 -0
- package/src/views/seo/seo-routes.ts +58 -0
- package/src/views/settings/AuditLogExportCard.tsx +131 -0
- 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
|
})
|
package/src/lib/pages-service.ts
CHANGED
|
@@ -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>(
|
|
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/
|
|
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
|
|
21
|
-
*
|
|
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 [
|
|
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
|
-
|
|
232
|
+
resetCursor()
|
|
181
233
|
}, 300)
|
|
182
234
|
return () => clearTimeout(searchTimer.current)
|
|
183
|
-
}, [search])
|
|
184
|
-
|
|
185
|
-
const endpoint = useMemo(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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<{
|
|
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 /
|
|
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
|
-
|
|
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(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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 {
|
|
703
|
+
Page {pageNumber} of {totalPages}
|
|
635
704
|
</span>
|
|
636
705
|
<div className="flex gap-2">
|
|
637
706
|
<button
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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
|