@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.
Files changed (138) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -4
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
  6. package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/inspector-pane.test.js +31 -0
  8. package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
  9. package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
  10. package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/pages-tree.test.js +173 -0
  12. package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
  13. package/dist/__tests__/views/dashboard.test.js +7 -3
  14. package/dist/__tests__/views/dashboard.test.js.map +1 -1
  15. package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
  16. package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
  18. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
  19. package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
  22. package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
  23. package/dist/__tests__/views/pages-list-view.test.js +77 -1
  24. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  25. package/dist/actuate-admin.css +1 -1
  26. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
  27. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
  28. package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
  29. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
  30. package/dist/components/ui/InspectorPane.d.ts +51 -0
  31. package/dist/components/ui/InspectorPane.d.ts.map +1 -0
  32. package/dist/components/ui/InspectorPane.js +42 -0
  33. package/dist/components/ui/InspectorPane.js.map +1 -0
  34. package/dist/components/ui/RowActions.d.ts +19 -0
  35. package/dist/components/ui/RowActions.d.ts.map +1 -0
  36. package/dist/components/ui/RowActions.js +16 -0
  37. package/dist/components/ui/RowActions.js.map +1 -0
  38. package/dist/components/ui/index.d.ts +4 -0
  39. package/dist/components/ui/index.d.ts.map +1 -1
  40. package/dist/components/ui/index.js +2 -0
  41. package/dist/components/ui/index.js.map +1 -1
  42. package/dist/layout/Header.js +1 -1
  43. package/dist/layout/Header.js.map +1 -1
  44. package/dist/layout/Layout.js +8 -38
  45. package/dist/layout/Layout.js.map +1 -1
  46. package/dist/lib/page-editor-service.d.ts +11 -2
  47. package/dist/lib/page-editor-service.d.ts.map +1 -1
  48. package/dist/lib/page-editor-service.js +8 -2
  49. package/dist/lib/page-editor-service.js.map +1 -1
  50. package/dist/lib/pages-service.d.ts +9 -0
  51. package/dist/lib/pages-service.d.ts.map +1 -1
  52. package/dist/lib/pages-service.js +14 -0
  53. package/dist/lib/pages-service.js.map +1 -1
  54. package/dist/lib/pages-tree.d.ts +44 -0
  55. package/dist/lib/pages-tree.d.ts.map +1 -0
  56. package/dist/lib/pages-tree.js +106 -0
  57. package/dist/lib/pages-tree.js.map +1 -0
  58. package/dist/lib/posts-service.d.ts +11 -0
  59. package/dist/lib/posts-service.d.ts.map +1 -1
  60. package/dist/lib/posts-service.js +13 -0
  61. package/dist/lib/posts-service.js.map +1 -1
  62. package/dist/lib/seo-service.d.ts +3 -0
  63. package/dist/lib/seo-service.d.ts.map +1 -1
  64. package/dist/lib/seo-service.js.map +1 -1
  65. package/dist/views/Dashboard.d.ts.map +1 -1
  66. package/dist/views/Dashboard.js +40 -3
  67. package/dist/views/Dashboard.js.map +1 -1
  68. package/dist/views/MediaBrowser.d.ts.map +1 -1
  69. package/dist/views/MediaBrowser.js +55 -2
  70. package/dist/views/MediaBrowser.js.map +1 -1
  71. package/dist/views/MediaSeoAudit.d.ts +25 -0
  72. package/dist/views/MediaSeoAudit.d.ts.map +1 -0
  73. package/dist/views/MediaSeoAudit.js +97 -0
  74. package/dist/views/MediaSeoAudit.js.map +1 -0
  75. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  76. package/dist/views/Pages/PagesListView.js +145 -16
  77. package/dist/views/Pages/PagesListView.js.map +1 -1
  78. package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
  79. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
  80. package/dist/views/Pages/PagesTreeTable.js +28 -0
  81. package/dist/views/Pages/PagesTreeTable.js.map +1 -0
  82. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  83. package/dist/views/Posts/PostsListView.js +66 -9
  84. package/dist/views/Posts/PostsListView.js.map +1 -1
  85. package/dist/views/ScriptTags.d.ts +5 -1
  86. package/dist/views/ScriptTags.d.ts.map +1 -1
  87. package/dist/views/ScriptTags.js +32 -7
  88. package/dist/views/ScriptTags.js.map +1 -1
  89. package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
  90. package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
  91. package/dist/views/dashboard/OnboardingBlock.js +71 -0
  92. package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
  93. package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
  94. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  95. package/dist/views/page-editor/PageSectionEditor.js +3 -3
  96. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  97. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  98. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  99. package/dist/views/page-editor/PageSettingsModal.js +42 -3
  100. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  101. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  102. package/dist/views/seo/TechnicalTab.js +15 -0
  103. package/dist/views/seo/TechnicalTab.js.map +1 -1
  104. package/package.json +3 -3
  105. package/src/AdminRoot.tsx +7 -3
  106. package/src/__tests__/components/inspector-pane.test.tsx +64 -0
  107. package/src/__tests__/lib/pages-tree.test.ts +197 -0
  108. package/src/__tests__/views/dashboard.test.tsx +7 -3
  109. package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
  110. package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
  111. package/src/__tests__/views/pages-list-view.test.tsx +93 -1
  112. package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
  113. package/src/components/ui/InspectorPane.tsx +129 -0
  114. package/src/components/ui/RowActions.tsx +50 -0
  115. package/src/components/ui/index.ts +12 -0
  116. package/src/layout/Header.tsx +2 -2
  117. package/src/layout/Layout.tsx +8 -80
  118. package/src/lib/page-editor-service.ts +16 -2
  119. package/src/lib/pages-service.ts +18 -0
  120. package/src/lib/pages-tree.ts +145 -0
  121. package/src/lib/posts-service.ts +26 -0
  122. package/src/lib/seo-service.ts +3 -0
  123. package/src/views/Dashboard.tsx +286 -214
  124. package/src/views/MediaBrowser.tsx +673 -512
  125. package/src/views/MediaSeoAudit.tsx +320 -0
  126. package/src/views/Pages/PagesListView.tsx +459 -185
  127. package/src/views/Pages/PagesTreeTable.tsx +339 -0
  128. package/src/views/Posts/PostsListView.tsx +135 -10
  129. package/src/views/ScriptTags.tsx +204 -129
  130. package/src/views/dashboard/OnboardingBlock.tsx +233 -0
  131. package/src/views/page-editor/PageSectionEditor.tsx +9 -2
  132. package/src/views/page-editor/PageSettingsModal.tsx +75 -2
  133. package/src/views/seo/TechnicalTab.tsx +15 -0
  134. package/dist/views/ScriptTagEditor.d.ts +0 -6
  135. package/dist/views/ScriptTagEditor.d.ts.map +0 -1
  136. package/dist/views/ScriptTagEditor.js +0 -111
  137. package/dist/views/ScriptTagEditor.js.map +0 -1
  138. package/src/views/ScriptTagEditor.tsx +0 -353
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useMemo, useState, useEffect, type ReactNode } from 'react'
4
- import { ExternalLink, Plus } from 'lucide-react'
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 / home"+ New Post" + "View Site"
106
- * - List views just "+ New <Type>"
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
- // Dashboard: keep the existing "+ New Post" / "View Site" pair. With
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
- /** A blank page scaffold for `/pages/new`. */
279
- export function emptyPage(): EditorPage {
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: '',
@@ -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
+ }
@@ -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 }> {
@@ -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> {