@actuate-media/cms-admin 0.65.0 → 0.67.0
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 +39 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +7 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
- package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
- package/dist/__tests__/components/inspector-pane.test.js +31 -0
- package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.js +173 -0
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
- package/dist/__tests__/views/dashboard.test.js +7 -3
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
- package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +77 -1
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
- package/dist/components/ui/InspectorPane.d.ts +51 -0
- package/dist/components/ui/InspectorPane.d.ts.map +1 -0
- package/dist/components/ui/InspectorPane.js +42 -0
- package/dist/components/ui/InspectorPane.js.map +1 -0
- package/dist/components/ui/RowActions.d.ts +19 -0
- package/dist/components/ui/RowActions.d.ts.map +1 -0
- package/dist/components/ui/RowActions.js +16 -0
- package/dist/components/ui/RowActions.js.map +1 -0
- package/dist/components/ui/index.d.ts +4 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +2 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.js +8 -38
- package/dist/layout/Layout.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +11 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +8 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +9 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +14 -0
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +44 -0
- package/dist/lib/pages-tree.d.ts.map +1 -0
- package/dist/lib/pages-tree.js +106 -0
- package/dist/lib/pages-tree.js.map +1 -0
- package/dist/lib/posts-service.d.ts +11 -0
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +13 -0
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +3 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +40 -3
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +55 -2
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +25 -0
- package/dist/views/MediaSeoAudit.d.ts.map +1 -0
- package/dist/views/MediaSeoAudit.js +97 -0
- package/dist/views/MediaSeoAudit.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +145 -16
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
- package/dist/views/Pages/PagesTreeTable.js +28 -0
- package/dist/views/Pages/PagesTreeTable.js.map +1 -0
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +66 -9
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/ScriptTags.d.ts +5 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +32 -7
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
- package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
- package/dist/views/dashboard/OnboardingBlock.js +71 -0
- package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -3
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +42 -3
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +15 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/package.json +3 -3
- package/src/AdminRoot.tsx +7 -3
- package/src/__tests__/components/inspector-pane.test.tsx +64 -0
- package/src/__tests__/lib/pages-tree.test.ts +197 -0
- package/src/__tests__/views/dashboard.test.tsx +7 -3
- package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
- package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
- package/src/__tests__/views/pages-list-view.test.tsx +93 -1
- package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
- package/src/components/ui/InspectorPane.tsx +129 -0
- package/src/components/ui/RowActions.tsx +50 -0
- package/src/components/ui/index.ts +12 -0
- package/src/layout/Header.tsx +2 -2
- package/src/layout/Layout.tsx +8 -80
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-service.ts +18 -0
- package/src/lib/pages-tree.ts +145 -0
- package/src/lib/posts-service.ts +26 -0
- package/src/lib/seo-service.ts +3 -0
- package/src/views/Dashboard.tsx +286 -214
- package/src/views/MediaBrowser.tsx +673 -512
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +459 -185
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- package/src/views/Posts/PostsListView.tsx +135 -10
- package/src/views/ScriptTags.tsx +204 -129
- package/src/views/dashboard/OnboardingBlock.tsx +233 -0
- package/src/views/page-editor/PageSectionEditor.tsx +9 -2
- package/src/views/page-editor/PageSettingsModal.tsx +75 -2
- package/src/views/seo/TechnicalTab.tsx +15 -0
- package/dist/views/ScriptTagEditor.d.ts +0 -6
- package/dist/views/ScriptTagEditor.d.ts.map +0 -1
- package/dist/views/ScriptTagEditor.js +0 -111
- package/dist/views/ScriptTagEditor.js.map +0 -1
- package/src/views/ScriptTagEditor.tsx +0 -353
package/src/layout/Layout.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useMemo, useState, useEffect, type ReactNode } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { Plus } from 'lucide-react'
|
|
5
5
|
import { Sidebar } from './Sidebar.js'
|
|
6
6
|
import { Header } from './Header.js'
|
|
7
7
|
import { Breadcrumbs } from '../components/Breadcrumbs.js'
|
|
@@ -102,8 +102,9 @@ interface PageActionsContext {
|
|
|
102
102
|
* primary/secondary pair that mirrors the reference dashboard.
|
|
103
103
|
*
|
|
104
104
|
* Resolution rules:
|
|
105
|
-
* - Dashboard /
|
|
106
|
-
*
|
|
105
|
+
* - Dashboard / Posts / Pages → nothing here; those views render their
|
|
106
|
+
* hero CTAs inside the page header per the design
|
|
107
|
+
* - Other list views → just "+ New <Type>"
|
|
107
108
|
* - Editor views → no top-bar action (editor renders its own toolbar)
|
|
108
109
|
*/
|
|
109
110
|
function computePageActions({
|
|
@@ -112,56 +113,12 @@ function computePageActions({
|
|
|
112
113
|
onNavigate,
|
|
113
114
|
}: PageActionsContext): ReactNode | null {
|
|
114
115
|
const collections = collectionList(config)
|
|
115
|
-
const postCollections = collections.filter((c) => c.type === 'post')
|
|
116
|
-
const newPostPath = resolveNewPostPath(postCollections)
|
|
117
|
-
const siteUrl: string | null = config?.site?.url ?? config?.seo?.siteUrl ?? null
|
|
118
116
|
|
|
117
|
+
// Dashboard, /posts, and /pages render their own hero CTAs in the page
|
|
118
|
+
// header (design `.page-hdr` → `.hdr-r`), so the top bar stays empty there.
|
|
119
119
|
const isDashboard = currentPath === '/' || currentPath === '/dashboard'
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// a single Post Type we route straight to its editor; with several we
|
|
123
|
-
// route to the picker so the user can choose the type first.
|
|
124
|
-
if (isDashboard) {
|
|
125
|
-
return (
|
|
126
|
-
<>
|
|
127
|
-
<button
|
|
128
|
-
type="button"
|
|
129
|
-
onClick={() => onNavigate(newPostPath)}
|
|
130
|
-
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
131
|
-
>
|
|
132
|
-
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
133
|
-
New Post
|
|
134
|
-
</button>
|
|
135
|
-
{siteUrl ? (
|
|
136
|
-
<a
|
|
137
|
-
href={siteUrl}
|
|
138
|
-
target="_blank"
|
|
139
|
-
rel="noopener noreferrer"
|
|
140
|
-
className="border-border bg-background text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors"
|
|
141
|
-
>
|
|
142
|
-
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
|
|
143
|
-
View Site
|
|
144
|
-
</a>
|
|
145
|
-
) : null}
|
|
146
|
-
</>
|
|
147
|
-
)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Posts area — "+ New Post" + "Manage types" on the list page;
|
|
151
|
-
// "+ New Post Type" on the types page. The buttons live in the top
|
|
152
|
-
// bar so the page header stays clean. See the dashboard rule for
|
|
153
|
-
// why these come from a single config-aware helper.
|
|
154
|
-
if (currentPath === '/posts') {
|
|
155
|
-
return (
|
|
156
|
-
<button
|
|
157
|
-
type="button"
|
|
158
|
-
onClick={() => onNavigate(newPostPath)}
|
|
159
|
-
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
160
|
-
>
|
|
161
|
-
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
162
|
-
New Post
|
|
163
|
-
</button>
|
|
164
|
-
)
|
|
120
|
+
if (isDashboard || currentPath === '/posts' || currentPath === '/pages') {
|
|
121
|
+
return null
|
|
165
122
|
}
|
|
166
123
|
|
|
167
124
|
if (currentPath === '/posts/types') {
|
|
@@ -177,22 +134,6 @@ function computePageActions({
|
|
|
177
134
|
)
|
|
178
135
|
}
|
|
179
136
|
|
|
180
|
-
// Pages list — "+ New page" routes to the page creation flow. Handled
|
|
181
|
-
// explicitly (not via the collection loop) so the label/route stay
|
|
182
|
-
// exact even though `pages` is a reserved route.
|
|
183
|
-
if (currentPath === '/pages') {
|
|
184
|
-
return (
|
|
185
|
-
<button
|
|
186
|
-
type="button"
|
|
187
|
-
onClick={() => onNavigate('/pages/new')}
|
|
188
|
-
className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
|
|
189
|
-
>
|
|
190
|
-
<Plus className="h-3.5 w-3.5" aria-hidden />
|
|
191
|
-
New page
|
|
192
|
-
</button>
|
|
193
|
-
)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
137
|
// List views — surface "+ New X" so authors can keep creating without
|
|
197
138
|
// hunting for a per-list button. Skip on editor views; those have their
|
|
198
139
|
// own toolbar.
|
|
@@ -221,19 +162,6 @@ interface CollectionMeta {
|
|
|
221
162
|
admin?: { hidden?: boolean }
|
|
222
163
|
}
|
|
223
164
|
|
|
224
|
-
/**
|
|
225
|
-
* Where "+ New Post" should land, given the configured post-type
|
|
226
|
-
* collections:
|
|
227
|
-
* - exactly one → straight to that type's editor (`/<slug>/new`)
|
|
228
|
-
* - more than one → the type picker (`/posts/new`)
|
|
229
|
-
* - none → the Post Type setup instructions
|
|
230
|
-
*/
|
|
231
|
-
function resolveNewPostPath(postCollections: CollectionMeta[]): string {
|
|
232
|
-
if (postCollections.length === 1) return `/${postCollections[0]!.slug}/new`
|
|
233
|
-
if (postCollections.length > 1) return '/posts/new'
|
|
234
|
-
return '/posts/types/new'
|
|
235
|
-
}
|
|
236
|
-
|
|
237
165
|
function collectionList(config: any): CollectionMeta[] {
|
|
238
166
|
if (!config?.collections) return []
|
|
239
167
|
const raw = config.collections
|
|
@@ -64,6 +64,12 @@ export interface EditorPage {
|
|
|
64
64
|
slug: string
|
|
65
65
|
path: string
|
|
66
66
|
status: PageStatus
|
|
67
|
+
/**
|
|
68
|
+
* Parent page for the tree hierarchy (`data.parentPageId`), or null for
|
|
69
|
+
* a top-level page. Preselected when arriving via "Add sub-page"
|
|
70
|
+
* (`/pages/new?parent=<id>`); editable in Page settings.
|
|
71
|
+
*/
|
|
72
|
+
parentPageId: string | null
|
|
67
73
|
sections: PageSection[]
|
|
68
74
|
seoTitle: string
|
|
69
75
|
seoDescription: string
|
|
@@ -192,6 +198,8 @@ function rowToEditorPage(doc: DocumentApiRow): EditorPage {
|
|
|
192
198
|
slug,
|
|
193
199
|
path: typeof data.path === 'string' && data.path ? data.path : slug ? `/${slug}` : '/',
|
|
194
200
|
status: doc.status ?? 'DRAFT',
|
|
201
|
+
parentPageId:
|
|
202
|
+
typeof data.parentPageId === 'string' && data.parentPageId ? data.parentPageId : null,
|
|
195
203
|
// Preserve unknown (externally-managed) sections so the editor can show
|
|
196
204
|
// them read-only and round-trip them on save instead of dropping them.
|
|
197
205
|
sections: coerceSectionsCore(data.sections, { onUnknown: 'preserve' }),
|
|
@@ -215,6 +223,7 @@ interface PageWritePayload {
|
|
|
215
223
|
title: string
|
|
216
224
|
slug: string
|
|
217
225
|
path: string
|
|
226
|
+
parentPageId: string | null
|
|
218
227
|
sections: PageSection[]
|
|
219
228
|
metaTitle: string
|
|
220
229
|
metaDescription: string
|
|
@@ -230,6 +239,7 @@ function toWriteBody(page: EditorPage, extra?: Record<string, unknown>): string
|
|
|
230
239
|
title: page.title,
|
|
231
240
|
slug: page.slug,
|
|
232
241
|
path: page.path,
|
|
242
|
+
parentPageId: page.parentPageId,
|
|
233
243
|
// Unmanaged sections are part of `page.sections`, so they round-trip on
|
|
234
244
|
// every save (no data loss). Strip the derived `unmanaged` marker — it is
|
|
235
245
|
// recomputed from the registry on read, so persisting it would only stale.
|
|
@@ -275,14 +285,18 @@ export async function publishPage(page: EditorPage): Promise<EditorPage> {
|
|
|
275
285
|
return rowToEditorPage(res.data)
|
|
276
286
|
}
|
|
277
287
|
|
|
278
|
-
/**
|
|
279
|
-
|
|
288
|
+
/**
|
|
289
|
+
* A blank page scaffold for `/pages/new`. `parentPageId` is preset when
|
|
290
|
+
* the flow was entered via "Add sub-page" in the Pages tree view.
|
|
291
|
+
*/
|
|
292
|
+
export function emptyPage(parentPageId: string | null = null): EditorPage {
|
|
280
293
|
return {
|
|
281
294
|
id: null,
|
|
282
295
|
title: '',
|
|
283
296
|
slug: '',
|
|
284
297
|
path: '',
|
|
285
298
|
status: 'DRAFT',
|
|
299
|
+
parentPageId,
|
|
286
300
|
sections: [],
|
|
287
301
|
seoTitle: '',
|
|
288
302
|
seoDescription: '',
|
package/src/lib/pages-service.ts
CHANGED
|
@@ -510,6 +510,24 @@ export async function bulkUpdatePages(
|
|
|
510
510
|
})
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
+
/** Duplicate a single page. Server resets status to DRAFT and appends "(Copy)". */
|
|
514
|
+
export async function duplicatePage(id: string): Promise<{ id?: string; error?: string }> {
|
|
515
|
+
const res = await cmsApi<DocumentApiRow>(
|
|
516
|
+
`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}/duplicate`,
|
|
517
|
+
{ method: 'POST' },
|
|
518
|
+
)
|
|
519
|
+
if (res.error) return { error: res.error }
|
|
520
|
+
return { id: res.data?.id }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Soft-delete a single page. */
|
|
524
|
+
export async function deletePage(id: string): Promise<{ error?: string }> {
|
|
525
|
+
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
|
526
|
+
method: 'DELETE',
|
|
527
|
+
})
|
|
528
|
+
return res.error ? { error: res.error } : {}
|
|
529
|
+
}
|
|
530
|
+
|
|
513
531
|
export async function bulkDeletePages(ids: string[]): Promise<BulkResult> {
|
|
514
532
|
return mapWithConcurrency(ids, 5, async (id) => {
|
|
515
533
|
const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page hierarchy resolution for the Pages tree view.
|
|
3
|
+
*
|
|
4
|
+
* Parent resolution strategy (in priority order):
|
|
5
|
+
* 1. `parentPageId` — explicit link, used when it points at a page that
|
|
6
|
+
* exists in the set (and isn't the page itself).
|
|
7
|
+
* 2. Path-prefix derivation — for rows without a usable `parentPageId`,
|
|
8
|
+
* the longest existing path prefix wins: "/about/team" becomes a child
|
|
9
|
+
* of the page whose path is "/about". Missing intermediate levels are
|
|
10
|
+
* skipped ("/a/b/c" with no "/a/b" attaches to "/a"). The root path
|
|
11
|
+
* "/" is never treated as a prefix parent, so top-level pages stay
|
|
12
|
+
* siblings of the homepage.
|
|
13
|
+
*
|
|
14
|
+
* Safety guarantees:
|
|
15
|
+
* - Orphans (parent id not in the set) become roots.
|
|
16
|
+
* - Cycles (a → b → a) are broken deterministically: walking up from each
|
|
17
|
+
* page in input order, the edge that closes a cycle is severed and that
|
|
18
|
+
* page becomes a root. Every page appears in the tree exactly once.
|
|
19
|
+
* - Sibling ordering is stable: title (locale-aware), then path.
|
|
20
|
+
*
|
|
21
|
+
* Pure functions — no network, no React — so the view and the unit tests
|
|
22
|
+
* share one implementation.
|
|
23
|
+
*/
|
|
24
|
+
import type { Page } from './pages-service.js'
|
|
25
|
+
|
|
26
|
+
export interface PageTreeNode {
|
|
27
|
+
page: Page
|
|
28
|
+
children: PageTreeNode[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One visible row of the flattened tree table. */
|
|
32
|
+
export interface PageTreeRow {
|
|
33
|
+
page: Page
|
|
34
|
+
depth: number
|
|
35
|
+
hasChildren: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizePath(path: string): string {
|
|
39
|
+
let p = path.trim()
|
|
40
|
+
if (!p.startsWith('/')) p = `/${p}`
|
|
41
|
+
p = p.replace(/\/{2,}/g, '/')
|
|
42
|
+
if (p.length > 1) p = p.replace(/\/+$/, '')
|
|
43
|
+
return p
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Longest existing path-prefix parent, or null. Only prefixes with at
|
|
48
|
+
* least one segment count — "/" (the homepage) is never a derived parent.
|
|
49
|
+
*/
|
|
50
|
+
function derivePathParent(page: Page, idByPath: Map<string, string>): string | null {
|
|
51
|
+
const segments = normalizePath(page.path).split('/').filter(Boolean)
|
|
52
|
+
for (let i = segments.length - 1; i >= 1; i--) {
|
|
53
|
+
const prefix = `/${segments.slice(0, i).join('/')}`
|
|
54
|
+
const candidate = idByPath.get(prefix)
|
|
55
|
+
if (candidate !== undefined && candidate !== page.id) return candidate
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function compareSiblings(a: PageTreeNode, b: PageTreeNode): number {
|
|
61
|
+
return (
|
|
62
|
+
a.page.title.localeCompare(b.page.title) ||
|
|
63
|
+
a.page.path.localeCompare(b.page.path) ||
|
|
64
|
+
a.page.id.localeCompare(b.page.id)
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build the parent/child hierarchy for the tree view. Returns root nodes. */
|
|
69
|
+
export function buildPageTree(pages: Page[]): PageTreeNode[] {
|
|
70
|
+
const byId = new Map<string, Page>()
|
|
71
|
+
for (const p of pages) byId.set(p.id, p)
|
|
72
|
+
|
|
73
|
+
// First occurrence wins on duplicate paths, keeping resolution stable
|
|
74
|
+
// with respect to input order.
|
|
75
|
+
const idByPath = new Map<string, string>()
|
|
76
|
+
for (const p of pages) {
|
|
77
|
+
const norm = normalizePath(p.path)
|
|
78
|
+
if (!idByPath.has(norm)) idByPath.set(norm, p.id)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parentOf = new Map<string, string | null>()
|
|
82
|
+
for (const p of pages) {
|
|
83
|
+
const explicit =
|
|
84
|
+
p.parentPageId && p.parentPageId !== p.id && byId.has(p.parentPageId) ? p.parentPageId : null
|
|
85
|
+
parentOf.set(p.id, explicit ?? derivePathParent(p, idByPath))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Cycle guard: walk each page's ancestor chain; the edge that revisits a
|
|
89
|
+
// seen node is severed, rooting that page. Deterministic in input order.
|
|
90
|
+
for (const p of pages) {
|
|
91
|
+
const seen = new Set<string>([p.id])
|
|
92
|
+
let cur = p.id
|
|
93
|
+
for (;;) {
|
|
94
|
+
const parent = parentOf.get(cur) ?? null
|
|
95
|
+
if (parent === null) break
|
|
96
|
+
if (seen.has(parent)) {
|
|
97
|
+
parentOf.set(cur, null)
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
seen.add(parent)
|
|
101
|
+
cur = parent
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const nodeById = new Map<string, PageTreeNode>()
|
|
106
|
+
for (const p of pages) nodeById.set(p.id, { page: p, children: [] })
|
|
107
|
+
|
|
108
|
+
const roots: PageTreeNode[] = []
|
|
109
|
+
for (const p of pages) {
|
|
110
|
+
const node = nodeById.get(p.id)!
|
|
111
|
+
const parentId = parentOf.get(p.id) ?? null
|
|
112
|
+
const parentNode = parentId !== null ? nodeById.get(parentId) : undefined
|
|
113
|
+
if (parentNode) parentNode.children.push(node)
|
|
114
|
+
else roots.push(node)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sortRecursive = (nodes: PageTreeNode[]) => {
|
|
118
|
+
nodes.sort(compareSiblings)
|
|
119
|
+
for (const n of nodes) sortRecursive(n.children)
|
|
120
|
+
}
|
|
121
|
+
sortRecursive(roots)
|
|
122
|
+
return roots
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Flatten the tree into visible rows: children render only when their
|
|
127
|
+
* parent's id is in `expanded`.
|
|
128
|
+
*/
|
|
129
|
+
export function flattenPageTree(nodes: PageTreeNode[], expanded: Set<string>): PageTreeRow[] {
|
|
130
|
+
const rows: PageTreeRow[] = []
|
|
131
|
+
const walk = (list: PageTreeNode[], depth: number) => {
|
|
132
|
+
for (const node of list) {
|
|
133
|
+
const hasChildren = node.children.length > 0
|
|
134
|
+
rows.push({ page: node.page, depth, hasChildren })
|
|
135
|
+
if (hasChildren && expanded.has(node.page.id)) walk(node.children, depth + 1)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
walk(nodes, 0)
|
|
139
|
+
return rows
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Default expand state: every root node that has children starts open. */
|
|
143
|
+
export function defaultExpandedIds(roots: PageTreeNode[]): Set<string> {
|
|
144
|
+
return new Set(roots.filter((n) => n.children.length > 0).map((n) => n.page.id))
|
|
145
|
+
}
|
package/src/lib/posts-service.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface Post {
|
|
|
50
50
|
publishDate: string | null
|
|
51
51
|
updatedDate: string
|
|
52
52
|
seoScore: number | null
|
|
53
|
+
/** Featured-image URL for the list thumbnail, when the document has one. */
|
|
54
|
+
imageUrl: string | null
|
|
53
55
|
createdAt: string
|
|
54
56
|
updatedAt: string
|
|
55
57
|
}
|
|
@@ -151,6 +153,7 @@ interface PostsApiPost {
|
|
|
151
153
|
createdAt: string
|
|
152
154
|
updatedAt: string
|
|
153
155
|
seoScore: number | null
|
|
156
|
+
image?: string | null
|
|
154
157
|
author: { id: string; name: string } | null
|
|
155
158
|
}
|
|
156
159
|
|
|
@@ -240,6 +243,7 @@ export async function fetchPosts(query: PostsQuery = {}): Promise<PostsResult> {
|
|
|
240
243
|
publishDate: d.publishedAt ?? d.scheduledAt ?? null,
|
|
241
244
|
updatedDate: pickDisplayDate(d),
|
|
242
245
|
seoScore: d.seoScore,
|
|
246
|
+
imageUrl: d.image ?? null,
|
|
243
247
|
createdAt: d.createdAt,
|
|
244
248
|
updatedAt: d.updatedAt,
|
|
245
249
|
}
|
|
@@ -282,6 +286,28 @@ export async function bulkUpdatePosts(
|
|
|
282
286
|
return { ok, failed }
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
/** Duplicate a single post. Server resets status to DRAFT and appends "(Copy)". */
|
|
290
|
+
export async function duplicatePost(
|
|
291
|
+
postTypeId: string,
|
|
292
|
+
id: string,
|
|
293
|
+
): Promise<{ id?: string; error?: string }> {
|
|
294
|
+
const res = await cmsApi<{ id?: string }>(
|
|
295
|
+
`/collections/${encodeURIComponent(postTypeId)}/${encodeURIComponent(id)}/duplicate`,
|
|
296
|
+
{ method: 'POST' },
|
|
297
|
+
)
|
|
298
|
+
if (res.error) return { error: res.error }
|
|
299
|
+
return { id: res.data?.id }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Soft-delete a single post. */
|
|
303
|
+
export async function deletePost(postTypeId: string, id: string): Promise<{ error?: string }> {
|
|
304
|
+
const res = await cmsApi(
|
|
305
|
+
`/collections/${encodeURIComponent(postTypeId)}/${encodeURIComponent(id)}`,
|
|
306
|
+
{ method: 'DELETE' },
|
|
307
|
+
)
|
|
308
|
+
return res.error ? { error: res.error } : {}
|
|
309
|
+
}
|
|
310
|
+
|
|
285
311
|
export async function bulkDeletePosts(
|
|
286
312
|
ids: Array<{ postTypeId: string; id: string }>,
|
|
287
313
|
): Promise<{ ok: number; failed: number }> {
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -635,6 +635,9 @@ export interface CrawlSettings {
|
|
|
635
635
|
indexEntireSite: boolean
|
|
636
636
|
includeImagesInSitemap: boolean
|
|
637
637
|
includePostsInSitemap: boolean
|
|
638
|
+
sitemapEnabled: boolean
|
|
639
|
+
autoRegenerateSitemapOnPublish: boolean
|
|
640
|
+
pingSearchEnginesOnPublish: boolean
|
|
638
641
|
}
|
|
639
642
|
|
|
640
643
|
export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
|