@actuate-media/cms-admin 0.66.0 → 0.68.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 +37 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +36 -9
- 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__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
- package/dist/__tests__/hooks/useIdleLogout.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__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/dashboard.test.js +7 -3
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.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/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.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/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- 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/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- 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/StatusBadge.d.ts +1 -1
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/hooks/useIdleLogout.d.ts +18 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +66 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.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-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/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +31 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -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 +90 -10
- 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/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.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-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- 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/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +3 -3
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +43 -10
- package/src/__tests__/components/inspector-pane.test.tsx +64 -0
- package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
- package/src/__tests__/lib/pages-tree.test.ts +197 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/dashboard.test.tsx +7 -3
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- 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/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/pages-list-view.test.tsx +93 -1
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/components/ui/InspectorPane.tsx +129 -0
- package/src/components/ui/index.ts +10 -0
- package/src/hooks/useIdleLogout.ts +77 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-tree.ts +145 -0
- package/src/lib/post-types-service.ts +129 -0
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Dashboard.tsx +238 -196
- package/src/views/Forms.tsx +154 -126
- package/src/views/MediaBrowser.tsx +641 -511
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +352 -183
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/ScriptTags.tsx +204 -129
- package/src/views/dashboard/OnboardingBlock.tsx +233 -0
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/page-editor/PageSectionEditor.tsx +9 -2
- package/src/views/page-editor/PageSettingsModal.tsx +75 -2
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +23 -2
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- 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/components/forms/FormSchemaDrawer.tsx +0 -192
- package/src/components/seo/SeoEditorDrawer.tsx +0 -529
- package/src/views/ScriptTagEditor.tsx +0 -353
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
/** How often the idle checker wakes up to compare against the deadline. */
|
|
6
|
+
const CHECK_INTERVAL_MS = 30_000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Signs the user out after `timeoutMs` of no user activity, when enabled by
|
|
10
|
+
* the Settings → Security "Idle auto-logout" policy.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately lightweight: activity events only stamp a ref (no state, no
|
|
13
|
+
* re-renders), and a single coarse interval compares the last-activity stamp
|
|
14
|
+
* against the deadline. Returning to a hidden tab re-checks immediately via
|
|
15
|
+
* `visibilitychange`, so a tab left overnight logs out as soon as it's seen
|
|
16
|
+
* (or at the next interval tick) rather than staying signed in.
|
|
17
|
+
*
|
|
18
|
+
* All listeners and the interval are torn down on unmount or when disabled.
|
|
19
|
+
*/
|
|
20
|
+
export function useIdleLogout(options: {
|
|
21
|
+
enabled: boolean
|
|
22
|
+
timeoutMs: number
|
|
23
|
+
onIdle: () => void
|
|
24
|
+
}): void {
|
|
25
|
+
const { enabled, timeoutMs } = options
|
|
26
|
+
// Keep the latest callback without re-subscribing listeners on every render.
|
|
27
|
+
const onIdleRef = useRef(options.onIdle)
|
|
28
|
+
onIdleRef.current = options.onIdle
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!enabled || timeoutMs <= 0 || typeof window === 'undefined') return
|
|
32
|
+
|
|
33
|
+
let lastActivity = Date.now()
|
|
34
|
+
let fired = false
|
|
35
|
+
|
|
36
|
+
const markActivity = () => {
|
|
37
|
+
lastActivity = Date.now()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const checkIdle = () => {
|
|
41
|
+
if (fired) return
|
|
42
|
+
if (Date.now() - lastActivity >= timeoutMs) {
|
|
43
|
+
fired = true
|
|
44
|
+
onIdleRef.current()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Passive listeners — activity stamping must never block scrolling/typing.
|
|
49
|
+
const activityEvents = [
|
|
50
|
+
'pointermove',
|
|
51
|
+
'pointerdown',
|
|
52
|
+
'keydown',
|
|
53
|
+
'scroll',
|
|
54
|
+
'touchstart',
|
|
55
|
+
] as const
|
|
56
|
+
for (const event of activityEvents) {
|
|
57
|
+
window.addEventListener(event, markActivity, { passive: true })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A tab becoming visible again is NOT user activity — check whether the
|
|
61
|
+
// deadline passed while it was hidden and log out immediately if so.
|
|
62
|
+
const onVisibility = () => {
|
|
63
|
+
if (document.visibilityState === 'visible') checkIdle()
|
|
64
|
+
}
|
|
65
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
66
|
+
|
|
67
|
+
const interval = window.setInterval(checkIdle, Math.min(CHECK_INTERVAL_MS, timeoutMs))
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
for (const event of activityEvents) {
|
|
71
|
+
window.removeEventListener(event, markActivity)
|
|
72
|
+
}
|
|
73
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
74
|
+
window.clearInterval(interval)
|
|
75
|
+
}
|
|
76
|
+
}, [enabled, timeoutMs])
|
|
77
|
+
}
|
package/src/lib/api.ts
CHANGED
|
@@ -65,7 +65,7 @@ function appendLocaleParam(endpoint: string): string {
|
|
|
65
65
|
export async function cmsApi<T = unknown>(
|
|
66
66
|
endpoint: string,
|
|
67
67
|
options: RequestInit = {},
|
|
68
|
-
): Promise<{ data?: T; error?: string; status: number }> {
|
|
68
|
+
): Promise<{ data?: T; error?: string; code?: string; status: number }> {
|
|
69
69
|
const url = `${basePath}${appendLocaleParam(endpoint)}`
|
|
70
70
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
|
71
71
|
|
|
@@ -96,7 +96,13 @@ export async function cmsApi<T = unknown>(
|
|
|
96
96
|
const json = await res.json().catch(() => ({}))
|
|
97
97
|
|
|
98
98
|
if (!res.ok) {
|
|
99
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
error: json.error || `Request failed (${res.status})`,
|
|
101
|
+
// Machine-readable error discriminator (e.g. IP_LOCKOUT_RISK) set by
|
|
102
|
+
// guard responses so callers can branch without parsing the message.
|
|
103
|
+
code: typeof json.code === 'string' ? json.code : undefined,
|
|
104
|
+
status: res.status,
|
|
105
|
+
}
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
return { data: json.data ?? json, status: res.status }
|
|
@@ -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: '',
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { cmsApi } from './api.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detail metadata for one post type (collection), sourced from the public
|
|
5
|
+
* `GET /collections` introspection endpoint. Read-only by design: collection
|
|
6
|
+
* schemas are file-defined in `actuate.config.ts`, so the Post Type editor
|
|
7
|
+
* inspects the live schema and generates config snippets for changes.
|
|
8
|
+
*/
|
|
9
|
+
export interface PostTypeFieldSummary {
|
|
10
|
+
name: string
|
|
11
|
+
type: string
|
|
12
|
+
required: boolean
|
|
13
|
+
relationTo?: string | string[]
|
|
14
|
+
options?: unknown[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PostTypeDetail {
|
|
18
|
+
slug: string
|
|
19
|
+
singular: string
|
|
20
|
+
plural: string
|
|
21
|
+
description: string | null
|
|
22
|
+
urlPrefix: string
|
|
23
|
+
icon: string | null
|
|
24
|
+
color: string | null
|
|
25
|
+
fields: PostTypeFieldSummary[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CollectionDetailRow {
|
|
29
|
+
slug: string
|
|
30
|
+
labels: { singular: string; plural: string }
|
|
31
|
+
type: string
|
|
32
|
+
urlPrefix: string
|
|
33
|
+
description: string | null
|
|
34
|
+
icon: string | null
|
|
35
|
+
color: string | null
|
|
36
|
+
hidden: boolean
|
|
37
|
+
fields: PostTypeFieldSummary[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function fetchPostTypeDetail(slug: string): Promise<PostTypeDetail | null> {
|
|
41
|
+
const res = await cmsApi<CollectionDetailRow[]>('/collections')
|
|
42
|
+
if (res.error || !res.data) {
|
|
43
|
+
throw new Error(res.error ?? 'Failed to load collections')
|
|
44
|
+
}
|
|
45
|
+
const row = res.data.find((c) => c.slug === slug)
|
|
46
|
+
if (!row) return null
|
|
47
|
+
return {
|
|
48
|
+
slug: row.slug,
|
|
49
|
+
singular: row.labels?.singular ?? slug,
|
|
50
|
+
plural: row.labels?.plural ?? slug,
|
|
51
|
+
description: row.description ?? null,
|
|
52
|
+
urlPrefix: row.urlPrefix || slug,
|
|
53
|
+
icon: row.icon ?? null,
|
|
54
|
+
color: row.color ?? null,
|
|
55
|
+
fields: Array.isArray(row.fields) ? row.fields : [],
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Friendly display names for cms-core field types (design's type badges). */
|
|
60
|
+
export const FIELD_TYPE_LABELS: Record<string, string> = {
|
|
61
|
+
text: 'Short Text',
|
|
62
|
+
richText: 'Rich Text',
|
|
63
|
+
number: 'Number',
|
|
64
|
+
boolean: 'Boolean',
|
|
65
|
+
date: 'Date',
|
|
66
|
+
slug: 'Slug',
|
|
67
|
+
select: 'Select',
|
|
68
|
+
relationship: 'Relation',
|
|
69
|
+
media: 'Media',
|
|
70
|
+
json: 'JSON',
|
|
71
|
+
color: 'Color',
|
|
72
|
+
url: 'URL',
|
|
73
|
+
email: 'Email',
|
|
74
|
+
array: 'List',
|
|
75
|
+
blocks: 'Blocks',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function fieldTypeLabel(type: string): string {
|
|
79
|
+
return FIELD_TYPE_LABELS[type] ?? type
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** "clientName" / "client_name" → "Client Name" for display. */
|
|
83
|
+
export function humanizeFieldName(name: string): string {
|
|
84
|
+
return name
|
|
85
|
+
.replace(/[_-]+/g, ' ')
|
|
86
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
87
|
+
.replace(/\s+/g, ' ')
|
|
88
|
+
.trim()
|
|
89
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** "Client Name" → "clientName" for generated field keys. */
|
|
93
|
+
export function fieldKeyFromName(name: string): string {
|
|
94
|
+
const words = name
|
|
95
|
+
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
|
96
|
+
.trim()
|
|
97
|
+
.split(/\s+/)
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
if (words.length === 0) return ''
|
|
100
|
+
return words
|
|
101
|
+
.map((w, i) => (i === 0 ? w.toLowerCase() : w[0]!.toUpperCase() + w.slice(1).toLowerCase()))
|
|
102
|
+
.join('')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface FieldSnippetInput {
|
|
106
|
+
key: string
|
|
107
|
+
label: string
|
|
108
|
+
type: string
|
|
109
|
+
required: boolean
|
|
110
|
+
helpText?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Paste-ready field entry for the collection's `fields` map in
|
|
115
|
+
* `actuate.config.ts` — same escape rules as the New Post Type snippet.
|
|
116
|
+
*/
|
|
117
|
+
export function renderFieldSnippet(input: FieldSnippetInput): string {
|
|
118
|
+
const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
|
119
|
+
const parts = [`type: '${input.type}'`, `label: '${esc(input.label)}'`]
|
|
120
|
+
if (input.required) parts.push('required: true')
|
|
121
|
+
if (input.helpText?.trim()) parts.push(`admin: { description: '${esc(input.helpText.trim())}' }`)
|
|
122
|
+
if (input.type === 'select') {
|
|
123
|
+
parts.push(`options: [{ label: 'Option A', value: 'option-a' }]`)
|
|
124
|
+
}
|
|
125
|
+
if (input.type === 'relationship') {
|
|
126
|
+
parts.push(`relationTo: 'posts'`)
|
|
127
|
+
}
|
|
128
|
+
return `${input.key}: { ${parts.join(', ')} },`
|
|
129
|
+
}
|