@actuate-media/cms-admin 0.72.3 → 0.74.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 +22 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/LoginRoot.d.ts.map +1 -1
- package/dist/LoginRoot.js +2 -1
- package/dist/LoginRoot.js.map +1 -1
- package/dist/__tests__/components/resizable-aside.test.d.ts +2 -0
- package/dist/__tests__/components/resizable-aside.test.d.ts.map +1 -0
- package/dist/__tests__/components/resizable-aside.test.js +77 -0
- package/dist/__tests__/components/resizable-aside.test.js.map +1 -0
- package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
- package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
- package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
- package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
- package/dist/__tests__/views/portal-container.test.js +13 -0
- package/dist/__tests__/views/portal-container.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +13 -4
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CoauthorResultPopover.js +1 -1
- package/dist/components/EditorSeoAside.d.ts +20 -0
- package/dist/components/EditorSeoAside.d.ts.map +1 -0
- package/dist/components/EditorSeoAside.js +30 -0
- package/dist/components/EditorSeoAside.js.map +1 -0
- package/dist/components/MediaPickerModal.js +1 -1
- package/dist/components/SEOConfigPanel.js +1 -1
- package/dist/components/SchedulePublishDialog.js +1 -1
- package/dist/components/SharePreviewLinkDialog.js +1 -1
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +2 -1
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/VersionHistory.js +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +28 -8
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +13 -3
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/components/seo/primitives.d.ts.map +1 -1
- package/dist/components/seo/primitives.js +3 -3
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/components/ui/Card.js +5 -5
- package/dist/components/ui/Card.js.map +1 -1
- package/dist/components/ui/InspectorPane.js +3 -3
- package/dist/components/ui/InspectorPane.js.map +1 -1
- package/dist/components/ui/PanelSection.d.ts +16 -0
- package/dist/components/ui/PanelSection.d.ts.map +1 -0
- package/dist/components/ui/PanelSection.js +15 -0
- package/dist/components/ui/PanelSection.js.map +1 -0
- package/dist/components/ui/ResizableAside.d.ts +38 -0
- package/dist/components/ui/ResizableAside.d.ts.map +1 -0
- package/dist/components/ui/ResizableAside.js +209 -0
- package/dist/components/ui/ResizableAside.js.map +1 -0
- package/dist/lib/admin-root-element.d.ts +9 -0
- package/dist/lib/admin-root-element.d.ts.map +1 -0
- package/dist/lib/admin-root-element.js +14 -0
- package/dist/lib/admin-root-element.js.map +1 -0
- package/dist/lib/portal-container.d.ts.map +1 -1
- package/dist/lib/portal-container.js +2 -3
- package/dist/lib/portal-container.js.map +1 -1
- package/dist/lib/seo-service.d.ts +89 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +50 -2
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.js +2 -2
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +12 -14
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +35 -6
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.js +1 -1
- package/dist/views/Posts/PostTypeEditor.js +3 -3
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +4 -2
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/ScriptTags.js +1 -1
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Users.js +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +4 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +5 -3
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +24 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +7 -2
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +1 -1
- package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeaderPanel.js +2 -1
- package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +24 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTemplateEditor.js +1 -1
- package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
- package/dist/views/seo/LinksTab.d.ts +4 -0
- package/dist/views/seo/LinksTab.d.ts.map +1 -0
- package/dist/views/seo/LinksTab.js +111 -0
- package/dist/views/seo/LinksTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +39 -6
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.js +2 -2
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/AppearanceTab.js +1 -1
- package/dist/views/settings/BrandVoiceCard.js +1 -1
- package/dist/views/settings/EmailSettingsTab.js +2 -2
- package/dist/views/settings/GeneralSettingsTab.js +1 -1
- package/dist/views/settings/SecurityStatusCard.js +1 -1
- package/dist/views/settings/SeoSettingsTab.js +4 -4
- package/dist/views/settings/UpdatesTab.js +2 -2
- package/dist/views/settings/components.js +3 -3
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +3 -2
- package/src/LoginRoot.tsx +2 -1
- package/src/__tests__/components/resizable-aside.test.tsx +130 -0
- package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
- package/src/__tests__/views/portal-container.test.tsx +14 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
- package/src/__tests__/views/seo-content-pane.render.test.tsx +14 -4
- package/src/components/CoauthorResultPopover.tsx +1 -1
- package/src/components/EditorSeoAside.tsx +149 -0
- package/src/components/MediaPickerModal.tsx +1 -1
- package/src/components/SEOConfigPanel.tsx +2 -2
- package/src/components/SchedulePublishDialog.tsx +1 -1
- package/src/components/SharePreviewLinkDialog.tsx +1 -1
- package/src/components/ThemeProvider.tsx +3 -1
- package/src/components/VersionHistory.tsx +1 -1
- package/src/components/seo/SeoEditorPane.tsx +41 -12
- package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
- package/src/components/seo/primitives.tsx +7 -3
- package/src/components/ui/Card.tsx +5 -5
- package/src/components/ui/InspectorPane.tsx +3 -3
- package/src/components/ui/PanelSection.tsx +53 -0
- package/src/components/ui/ResizableAside.tsx +317 -0
- package/src/lib/admin-root-element.ts +15 -0
- package/src/lib/portal-container.ts +3 -2
- package/src/lib/seo-service.ts +143 -2
- package/src/views/ApiKeys.tsx +2 -2
- package/src/views/Dashboard.tsx +40 -53
- package/src/views/MediaBrowser.tsx +62 -12
- package/src/views/PageEditor.tsx +1 -1
- package/src/views/Posts/PostTypeEditor.tsx +3 -3
- package/src/views/SEO.tsx +6 -0
- package/src/views/ScriptTags.tsx +2 -2
- package/src/views/Users.tsx +1 -1
- package/src/views/page-editor/EditorTopBar.tsx +24 -0
- package/src/views/page-editor/PageSectionEditor.tsx +44 -4
- package/src/views/page-editor/SectionInspector.tsx +33 -27
- package/src/views/page-editor/SectionsPanel.tsx +1 -1
- package/src/views/post-editor/PostHeaderPanel.tsx +16 -12
- package/src/views/post-editor/PostSectionEditor.tsx +44 -4
- package/src/views/post-editor/PostTemplateEditor.tsx +1 -1
- package/src/views/seo/LinksTab.tsx +462 -0
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +97 -4
- package/src/views/seo/TechnicalTab.tsx +2 -2
- package/src/views/settings/AISettingsTab.tsx +3 -3
- package/src/views/settings/AiFeaturesCard.tsx +1 -1
- package/src/views/settings/AppearanceTab.tsx +1 -1
- package/src/views/settings/BrandVoiceCard.tsx +2 -2
- package/src/views/settings/EmailSettingsTab.tsx +2 -2
- package/src/views/settings/GeneralSettingsTab.tsx +3 -3
- package/src/views/settings/SecurityStatusCard.tsx +1 -1
- package/src/views/settings/SeoSettingsTab.tsx +6 -6
- package/src/views/settings/UpdatesTab.tsx +3 -3
- package/src/views/settings/components.tsx +3 -3
package/src/AdminRoot.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import * as Dialog from '@radix-ui/react-dialog'
|
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { Layout } from './layout/Layout.js'
|
|
7
7
|
import { adminPortalContainer } from './lib/portal-container.js'
|
|
8
|
+
import { ADMIN_ROOT_ATTR, getAdminRootElement } from './lib/admin-root-element.js'
|
|
8
9
|
import { useAdminRouter, parseRouteQuery } from './router/index.js'
|
|
9
10
|
import { cmsApi, ensureCsrfToken } from './lib/api.js'
|
|
10
11
|
import { setDocumentFavicon, BRAND_UPDATED_EVENT } from './lib/favicon.js'
|
|
@@ -856,7 +857,7 @@ function useBranding(config: any) {
|
|
|
856
857
|
}
|
|
857
858
|
|
|
858
859
|
// --- Primary color ---
|
|
859
|
-
const adminEl =
|
|
860
|
+
const adminEl = getAdminRootElement()
|
|
860
861
|
if (branding?.primaryColor && adminEl) {
|
|
861
862
|
adminEl.style.setProperty('--primary', branding.primaryColor)
|
|
862
863
|
adminEl.style.setProperty('--sidebar-primary', branding.primaryColor)
|
|
@@ -938,7 +939,7 @@ export function AdminRoot(props: AdminRootProps) {
|
|
|
938
939
|
}, [props.sentryDsn])
|
|
939
940
|
|
|
940
941
|
return (
|
|
941
|
-
<div style={ISOLATION_STYLE} className="actuate-admin">
|
|
942
|
+
<div style={ISOLATION_STYLE} className="actuate-admin" {...{ [ADMIN_ROOT_ATTR]: '' }}>
|
|
942
943
|
<ThemeProvider defaultDarkMode={defaultDarkMode}>
|
|
943
944
|
<LocaleProvider config={props.config}>
|
|
944
945
|
<AdminSectionRenderersProvider value={props.sectionRenderers ?? EMPTY_RENDERERS}>
|
package/src/LoginRoot.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
|
|
|
4
4
|
import { useAdminRouter } from './router/index.js'
|
|
5
5
|
import { ThemeProvider } from './components/ThemeProvider.js'
|
|
6
6
|
import { ISOLATION_STYLE, useRobotsNoIndex } from './lib/root-chrome.js'
|
|
7
|
+
import { ADMIN_ROOT_ATTR } from './lib/admin-root-element.js'
|
|
7
8
|
import { Login, type CaptchaConfig } from './views/Login.js'
|
|
8
9
|
import { SetupWizard } from './views/SetupWizard.js'
|
|
9
10
|
import { ForgotPassword } from './views/ForgotPassword.js'
|
|
@@ -79,7 +80,7 @@ export function LoginRoot({
|
|
|
79
80
|
useRobotsNoIndex()
|
|
80
81
|
|
|
81
82
|
return (
|
|
82
|
-
<div style={ISOLATION_STYLE} className="actuate-admin">
|
|
83
|
+
<div style={ISOLATION_STYLE} className="actuate-admin" {...{ [ADMIN_ROOT_ATTR]: '' }}>
|
|
83
84
|
<ThemeProvider defaultDarkMode={defaultDarkMode}>
|
|
84
85
|
<LoginShell
|
|
85
86
|
config={config}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import {
|
|
5
|
+
InspectorExpandButton,
|
|
6
|
+
ResizableAside,
|
|
7
|
+
SECTION_INSPECTOR_DEFAULT_WIDTH,
|
|
8
|
+
SECTION_INSPECTOR_MIN_WIDTH,
|
|
9
|
+
SECTION_INSPECTOR_WIDTH_KEY,
|
|
10
|
+
computeInspectorMaxWidth,
|
|
11
|
+
computeInspectorOverlayWidth,
|
|
12
|
+
readSectionInspectorWidth,
|
|
13
|
+
} from '../../components/ui/ResizableAside.js'
|
|
14
|
+
|
|
15
|
+
const storage = new Map<string, string>()
|
|
16
|
+
vi.stubGlobal('localStorage', {
|
|
17
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
18
|
+
setItem: (key: string, value: string) => void storage.set(key, String(value)),
|
|
19
|
+
removeItem: (key: string) => void storage.delete(key),
|
|
20
|
+
clear: () => storage.clear(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
vi.stubGlobal(
|
|
24
|
+
'ResizeObserver',
|
|
25
|
+
class {
|
|
26
|
+
constructor(private callback: ResizeObserverCallback) {}
|
|
27
|
+
observe(target: Element) {
|
|
28
|
+
const width =
|
|
29
|
+
target instanceof HTMLElement
|
|
30
|
+
? Number.parseFloat(target.style.width) || target.clientWidth || 900
|
|
31
|
+
: 900
|
|
32
|
+
this.callback([{ contentRect: { width, height: 600 } } as ResizeObserverEntry], this)
|
|
33
|
+
}
|
|
34
|
+
unobserve() {}
|
|
35
|
+
disconnect() {}
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
storage.clear()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('inspector width helpers', () => {
|
|
44
|
+
it('caps inline max width to the container minus a canvas peek', () => {
|
|
45
|
+
expect(computeInspectorMaxWidth(1200)).toBe(640)
|
|
46
|
+
expect(computeInspectorMaxWidth(500)).toBe(404)
|
|
47
|
+
expect(computeInspectorMaxWidth(200)).toBe(SECTION_INSPECTOR_MIN_WIDTH)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('computes overlay width for narrow editor rows', () => {
|
|
51
|
+
expect(computeInspectorOverlayWidth(375)).toBe(343)
|
|
52
|
+
expect(computeInspectorOverlayWidth(768)).toBe(480)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('ResizableAside', () => {
|
|
57
|
+
it('renders a vertical resize separator with keyboard support', () => {
|
|
58
|
+
render(
|
|
59
|
+
<div style={{ width: 900 }}>
|
|
60
|
+
<ResizableAside aria-label="Section editor">
|
|
61
|
+
<p>Inspector content</p>
|
|
62
|
+
</ResizableAside>
|
|
63
|
+
</div>,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(screen.getByText('Inspector content')).toBeTruthy()
|
|
67
|
+
const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
|
|
68
|
+
expect(separator.getAttribute('aria-orientation')).toBe('vertical')
|
|
69
|
+
expect(separator.getAttribute('aria-valuemin')).toBe(String(SECTION_INSPECTOR_MIN_WIDTH))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('widens when dragging the separator left', () => {
|
|
73
|
+
render(
|
|
74
|
+
<div style={{ width: 900 }}>
|
|
75
|
+
<ResizableAside aria-label="Section editor">
|
|
76
|
+
<p>Inspector content</p>
|
|
77
|
+
</ResizableAside>
|
|
78
|
+
</div>,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const aside = screen.getByLabelText('Section editor')
|
|
82
|
+
const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
|
|
83
|
+
|
|
84
|
+
expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH}px`)
|
|
85
|
+
|
|
86
|
+
fireEvent.pointerDown(separator, { clientX: 900, button: 0, pointerId: 1 })
|
|
87
|
+
fireEvent.pointerMove(separator, { clientX: 860, pointerId: 1 })
|
|
88
|
+
expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH + 40}px`)
|
|
89
|
+
|
|
90
|
+
fireEvent.pointerUp(separator, { pointerId: 1 })
|
|
91
|
+
expect(localStorage.getItem(SECTION_INSPECTOR_WIDTH_KEY)).toBe(
|
|
92
|
+
String(SECTION_INSPECTOR_DEFAULT_WIDTH + 40),
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('nudges width with arrow keys and persists', () => {
|
|
97
|
+
render(
|
|
98
|
+
<div style={{ width: 900 }}>
|
|
99
|
+
<ResizableAside aria-label="Section editor">
|
|
100
|
+
<p>Inspector content</p>
|
|
101
|
+
</ResizableAside>
|
|
102
|
+
</div>,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const aside = screen.getByLabelText('Section editor')
|
|
106
|
+
const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
|
|
107
|
+
|
|
108
|
+
separator.focus()
|
|
109
|
+
fireEvent.keyDown(separator, { key: 'ArrowLeft' })
|
|
110
|
+
expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH + 16}px`)
|
|
111
|
+
expect(readSectionInspectorWidth()).toBe(SECTION_INSPECTOR_DEFAULT_WIDTH + 16)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('expands to overlay width on narrow containers', () => {
|
|
115
|
+
render(
|
|
116
|
+
<div style={{ width: 375 }}>
|
|
117
|
+
<ResizableAside aria-label="Section editor">
|
|
118
|
+
<InspectorExpandButton />
|
|
119
|
+
<p>Inspector content</p>
|
|
120
|
+
</ResizableAside>
|
|
121
|
+
</div>,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const aside = screen.getByLabelText('Section editor')
|
|
125
|
+
fireEvent.click(screen.getByRole('button', { name: 'Expand inspector panel' }))
|
|
126
|
+
|
|
127
|
+
expect(aside.getAttribute('data-overlay')).toBe('')
|
|
128
|
+
expect(aside.style.width).toBe(`${computeInspectorOverlayWidth(375)}px`)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cleanup, render, waitFor } from '@testing-library/react'
|
|
4
|
+
import { accentTokensToCssVars, generateAdminThemeTokens } from '@actuate-media/cms-core/appearance'
|
|
5
|
+
|
|
6
|
+
import { ThemeProvider } from '../../components/ThemeProvider.js'
|
|
7
|
+
import { ADMIN_ROOT_ATTR, getAdminRootElement } from '../../lib/admin-root-element.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression: accent overrides must land on the inner AdminRoot shell, not a
|
|
11
|
+
* consumer wrapper `.actuate-admin` that also defines static --acc tokens.
|
|
12
|
+
*/
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
const storage = new Map<string, string>()
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
storage.clear()
|
|
19
|
+
vi.stubGlobal('localStorage', {
|
|
20
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
21
|
+
setItem: (key: string, value: string) => storage.set(key, value),
|
|
22
|
+
removeItem: (key: string) => storage.delete(key),
|
|
23
|
+
clear: () => storage.clear(),
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function mountNestedAdminShell() {
|
|
28
|
+
const outer = document.createElement('div')
|
|
29
|
+
outer.className = 'actuate-admin'
|
|
30
|
+
document.body.appendChild(outer)
|
|
31
|
+
|
|
32
|
+
const inner = document.createElement('div')
|
|
33
|
+
inner.className = 'actuate-admin'
|
|
34
|
+
inner.setAttribute(ADMIN_ROOT_ATTR, '')
|
|
35
|
+
outer.appendChild(inner)
|
|
36
|
+
|
|
37
|
+
return { outer, inner }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('getAdminRootElement', () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
document.body.innerHTML = ''
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('prefers the marked admin shell over an outer wrapper', () => {
|
|
46
|
+
const { outer, inner } = mountNestedAdminShell()
|
|
47
|
+
expect(getAdminRootElement()).toBe(inner)
|
|
48
|
+
expect(getAdminRootElement()).not.toBe(outer)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('falls back to .actuate-admin when no marked root exists', () => {
|
|
52
|
+
const root = document.createElement('div')
|
|
53
|
+
root.className = 'actuate-admin'
|
|
54
|
+
document.body.appendChild(root)
|
|
55
|
+
expect(getAdminRootElement()).toBe(root)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('ThemeProvider accent application', () => {
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
document.body.innerHTML = ''
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('applies accent tokens to the marked inner admin root when nested', async () => {
|
|
65
|
+
const { outer, inner } = mountNestedAdminShell()
|
|
66
|
+
|
|
67
|
+
render(
|
|
68
|
+
<ThemeProvider>
|
|
69
|
+
<span>child</span>
|
|
70
|
+
</ThemeProvider>,
|
|
71
|
+
{ container: inner },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const expected = accentTokensToCssVars(
|
|
75
|
+
generateAdminThemeTokens({ adminAccentColor: 'purple' }, 'light'),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(inner.style.getPropertyValue('--acc')).toBe(expected['--acc']!)
|
|
80
|
+
})
|
|
81
|
+
expect(inner.style.getPropertyValue('--primary')).toBe(expected['--primary']!)
|
|
82
|
+
expect(outer.style.getPropertyValue('--acc')).toBe('')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
3
3
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
4
|
import { ConfirmDangerousSettingDialog } from '../../views/settings/components.js'
|
|
5
5
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
6
|
+
import { ADMIN_ROOT_ATTR } from '../../lib/admin-root-element.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Regression: theme tokens only exist under `.actuate-admin` (theme.css), so
|
|
@@ -24,11 +25,24 @@ describe('adminPortalContainer', () => {
|
|
|
24
25
|
it('resolves the admin root element', () => {
|
|
25
26
|
const root = document.createElement('div')
|
|
26
27
|
root.className = 'actuate-admin'
|
|
28
|
+
root.setAttribute(ADMIN_ROOT_ATTR, '')
|
|
27
29
|
document.body.appendChild(root)
|
|
28
30
|
expect(adminPortalContainer()).toBe(root)
|
|
29
31
|
root.remove()
|
|
30
32
|
})
|
|
31
33
|
|
|
34
|
+
it('prefers the marked shell when nested inside another .actuate-admin', () => {
|
|
35
|
+
const outer = document.createElement('div')
|
|
36
|
+
outer.className = 'actuate-admin'
|
|
37
|
+
const inner = document.createElement('div')
|
|
38
|
+
inner.className = 'actuate-admin'
|
|
39
|
+
inner.setAttribute(ADMIN_ROOT_ATTR, '')
|
|
40
|
+
outer.appendChild(inner)
|
|
41
|
+
document.body.appendChild(outer)
|
|
42
|
+
expect(adminPortalContainer()).toBe(inner)
|
|
43
|
+
outer.remove()
|
|
44
|
+
})
|
|
45
|
+
|
|
32
46
|
it('returns undefined when no admin root is mounted', () => {
|
|
33
47
|
document.querySelectorAll('.actuate-admin').forEach((el) => el.remove())
|
|
34
48
|
expect(adminPortalContainer()).toBeUndefined()
|
|
@@ -55,6 +55,12 @@ vi.mock('../../lib/seo-service.js', () => ({
|
|
|
55
55
|
dismissRedirectSuggestion: vi.fn(async () => ({})),
|
|
56
56
|
exportRedirectsCsv: vi.fn(async () => ({ csv: '' })),
|
|
57
57
|
importRedirectsCsv: vi.fn(async () => ({ imported: 0 })),
|
|
58
|
+
fetchRedirectRecoverySettings: vi.fn(async () => ({
|
|
59
|
+
aiRerank: false,
|
|
60
|
+
autoApply: false,
|
|
61
|
+
autoApplyMinConfidence: 0.85,
|
|
62
|
+
})),
|
|
63
|
+
saveRedirectRecoverySettings: vi.fn(async () => ({})),
|
|
58
64
|
}))
|
|
59
65
|
|
|
60
66
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Content SEO tab with the inline editor pane (design split-pane pattern —
|
|
@@ -18,6 +18,9 @@ function makeRecord(over: Record<string, unknown> = {}) {
|
|
|
18
18
|
url: 'https://example.com/',
|
|
19
19
|
canonicalUrl: null,
|
|
20
20
|
metaTitle: 'Home — Example',
|
|
21
|
+
metaTitlePage: 'Home',
|
|
22
|
+
siteName: 'Example',
|
|
23
|
+
titleTemplate: '%page% — %site%',
|
|
21
24
|
metaDescription: 'Welcome to Example.',
|
|
22
25
|
focusKeyword: null,
|
|
23
26
|
keyTakeaway: null,
|
|
@@ -54,6 +57,11 @@ const fetchSeoContentRecord = vi.fn(async (entityType: string, entityId: string)
|
|
|
54
57
|
})
|
|
55
58
|
const updateSeoFields = vi.fn(async (_t: string, _i: string, _p: unknown) => ({}))
|
|
56
59
|
|
|
60
|
+
vi.mock('@actuate-media/cms-core/client', async (importOriginal) => {
|
|
61
|
+
const actual = await importOriginal<typeof import('@actuate-media/cms-core/client')>()
|
|
62
|
+
return { ...actual }
|
|
63
|
+
})
|
|
64
|
+
|
|
57
65
|
vi.mock('../../lib/seo-service.js', () => ({
|
|
58
66
|
fetchSeoContentRecords: () => fetchSeoContentRecords(),
|
|
59
67
|
fetchSeoContentRecord: (t: string, i: string) => fetchSeoContentRecord(t, i),
|
|
@@ -99,8 +107,10 @@ describe('Content SEO inline editor pane', () => {
|
|
|
99
107
|
expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
|
|
100
108
|
expect(rowButton.getAttribute('aria-expanded')).toBe('true')
|
|
101
109
|
await waitFor(() => expect(fetchSeoContentRecord).toHaveBeenCalledWith('page', 'p1'))
|
|
102
|
-
//
|
|
103
|
-
expect(await screen.findByDisplayValue('Home
|
|
110
|
+
// Form loads the stored page portion; preview applies the site title template.
|
|
111
|
+
expect(await screen.findByDisplayValue('Home')).toBeTruthy()
|
|
112
|
+
const editor = screen.getByRole('region', { name: 'SEO editor' })
|
|
113
|
+
expect(within(editor).getAllByText('Home — Example').length).toBeGreaterThan(0)
|
|
104
114
|
})
|
|
105
115
|
|
|
106
116
|
it('toggles the pane closed when the same row is clicked again', async () => {
|
|
@@ -118,7 +128,7 @@ describe('Content SEO inline editor pane', () => {
|
|
|
118
128
|
render(<ContentTab />)
|
|
119
129
|
fireEvent.click(await screen.findByRole('button', { name: /Home/ }))
|
|
120
130
|
|
|
121
|
-
const titleInput = await screen.findByDisplayValue('Home
|
|
131
|
+
const titleInput = await screen.findByDisplayValue('Home')
|
|
122
132
|
fireEvent.change(titleInput, { target: { value: 'New Meta Title' } })
|
|
123
133
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
|
124
134
|
|
|
@@ -120,7 +120,7 @@ export function CoauthorResultPopover({
|
|
|
120
120
|
onClick={(e) => e.stopPropagation()}
|
|
121
121
|
className="bg-card w-full max-w-2xl rounded-lg shadow-2xl outline-none"
|
|
122
122
|
>
|
|
123
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
123
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
124
124
|
<div className="text-foreground flex items-center gap-2 text-sm font-medium">
|
|
125
125
|
<Sparkles className="text-primary h-4 w-4" aria-hidden />
|
|
126
126
|
<span>AI {coauthorActionLabel(action)}</span>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { Search, X } from 'lucide-react'
|
|
5
|
+
import { SeoCharCount } from './seo/SeoCharCount.js'
|
|
6
|
+
import { SeoEditorPane } from './seo/SeoEditorPane.js'
|
|
7
|
+
import { InspectorExpandButton, ResizableAside } from './ui/ResizableAside.js'
|
|
8
|
+
|
|
9
|
+
const SEO_TITLE_MIN = 30
|
|
10
|
+
const SEO_TITLE_MAX = 60
|
|
11
|
+
const SEO_DESC_MIN = 120
|
|
12
|
+
const SEO_DESC_MAX = 160
|
|
13
|
+
|
|
14
|
+
const INSPECTOR_SHELL = 'bg-card border-border flex h-full flex-col border-l'
|
|
15
|
+
const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
16
|
+
const INPUT =
|
|
17
|
+
'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
18
|
+
|
|
19
|
+
export interface EditorSeoAsideProps {
|
|
20
|
+
entityType: 'page' | 'post'
|
|
21
|
+
/** Saved document id — when absent, shows a draft-only meta form. */
|
|
22
|
+
entityId: string | null
|
|
23
|
+
title: string
|
|
24
|
+
seoTitle: string
|
|
25
|
+
seoDescription: string
|
|
26
|
+
canEdit: boolean
|
|
27
|
+
onClose: () => void
|
|
28
|
+
/** Sync basic meta fields on the in-memory editor model (draft flow). */
|
|
29
|
+
onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
|
|
30
|
+
/** Refetch the editor document after SEO API save (saved flow). */
|
|
31
|
+
onSaved?: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function DraftSeoForm({
|
|
35
|
+
title,
|
|
36
|
+
seoTitle,
|
|
37
|
+
seoDescription,
|
|
38
|
+
canEdit,
|
|
39
|
+
onDraftChange,
|
|
40
|
+
}: Pick<
|
|
41
|
+
EditorSeoAsideProps,
|
|
42
|
+
'title' | 'seoTitle' | 'seoDescription' | 'canEdit' | 'onDraftChange'
|
|
43
|
+
>) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
|
46
|
+
<p className="border-border bg-muted text-muted-foreground rounded-md border p-4 text-sm">
|
|
47
|
+
Save a draft first to unlock the full SEO editor — focus keyword, social previews, schema,
|
|
48
|
+
robots, and AI assists. You can still set a basic title and description below; they are
|
|
49
|
+
saved with the document.
|
|
50
|
+
</p>
|
|
51
|
+
<div>
|
|
52
|
+
<div className="mb-1 flex items-center justify-between">
|
|
53
|
+
<label className={`${LABEL} mb-0`} htmlFor="editor-seo-title">
|
|
54
|
+
SEO title
|
|
55
|
+
</label>
|
|
56
|
+
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
57
|
+
</div>
|
|
58
|
+
<input
|
|
59
|
+
id="editor-seo-title"
|
|
60
|
+
className={INPUT}
|
|
61
|
+
value={seoTitle}
|
|
62
|
+
disabled={!canEdit}
|
|
63
|
+
placeholder={title || 'Defaults to the page title'}
|
|
64
|
+
onChange={(e) => onDraftChange?.({ seoTitle: e.target.value })}
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
<div>
|
|
68
|
+
<div className="mb-1 flex items-center justify-between">
|
|
69
|
+
<label className={`${LABEL} mb-0`} htmlFor="editor-seo-desc">
|
|
70
|
+
SEO description
|
|
71
|
+
</label>
|
|
72
|
+
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
73
|
+
</div>
|
|
74
|
+
<textarea
|
|
75
|
+
id="editor-seo-desc"
|
|
76
|
+
className={`${INPUT} min-h-[96px] resize-y`}
|
|
77
|
+
value={seoDescription}
|
|
78
|
+
disabled={!canEdit}
|
|
79
|
+
placeholder="Summarize this content for search results (120–160 characters)"
|
|
80
|
+
onChange={(e) => onDraftChange?.({ seoDescription: e.target.value })}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Right-side SEO panel for the Page / Post section editors. */
|
|
88
|
+
export function EditorSeoAside({
|
|
89
|
+
entityType,
|
|
90
|
+
entityId,
|
|
91
|
+
title,
|
|
92
|
+
seoTitle,
|
|
93
|
+
seoDescription,
|
|
94
|
+
canEdit,
|
|
95
|
+
onClose,
|
|
96
|
+
onDraftChange,
|
|
97
|
+
onSaved,
|
|
98
|
+
}: EditorSeoAsideProps) {
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
function onKey(e: KeyboardEvent) {
|
|
101
|
+
if (e.key === 'Escape') onClose()
|
|
102
|
+
}
|
|
103
|
+
document.addEventListener('keydown', onKey)
|
|
104
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
105
|
+
}, [onClose])
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<ResizableAside role="dialog" aria-label="SEO settings" className={INSPECTOR_SHELL}>
|
|
109
|
+
{entityId ? (
|
|
110
|
+
<SeoEditorPane
|
|
111
|
+
entityType={entityType}
|
|
112
|
+
entityId={entityId}
|
|
113
|
+
onClose={onClose}
|
|
114
|
+
onSaved={onSaved}
|
|
115
|
+
/>
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
119
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
120
|
+
<Search className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
|
|
121
|
+
<div className="min-w-0">
|
|
122
|
+
<h2 className="text-foreground truncate text-sm font-medium">SEO</h2>
|
|
123
|
+
<p className="text-muted-foreground truncate text-xs">{title || 'Untitled'}</p>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
127
|
+
<InspectorExpandButton />
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={onClose}
|
|
131
|
+
aria-label="Close SEO panel"
|
|
132
|
+
className="text-muted-foreground hover:text-foreground hover:bg-accent flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
|
|
133
|
+
>
|
|
134
|
+
<X className="h-4 w-4" aria-hidden />
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<DraftSeoForm
|
|
139
|
+
title={title}
|
|
140
|
+
seoTitle={seoTitle}
|
|
141
|
+
seoDescription={seoDescription}
|
|
142
|
+
canEdit={canEdit}
|
|
143
|
+
onDraftChange={onDraftChange}
|
|
144
|
+
/>
|
|
145
|
+
</>
|
|
146
|
+
)}
|
|
147
|
+
</ResizableAside>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -138,7 +138,7 @@ export function MediaPickerModal({
|
|
|
138
138
|
aria-describedby={undefined}
|
|
139
139
|
className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-70 mx-4 flex max-h-[80vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border shadow-2xl focus:outline-none"
|
|
140
140
|
>
|
|
141
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
141
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
142
142
|
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
143
143
|
Insert Image
|
|
144
144
|
</Dialog.Title>
|
|
@@ -242,7 +242,7 @@ export function SEOConfigPanel() {
|
|
|
242
242
|
|
|
243
243
|
{/* Site-wide section */}
|
|
244
244
|
<section className="border-border bg-card rounded-lg border">
|
|
245
|
-
<header className="border-border flex items-center gap-2 border-b px-
|
|
245
|
+
<header className="border-border flex items-center gap-2 border-b px-6 py-4">
|
|
246
246
|
<Globe className="text-primary h-4 w-4" />
|
|
247
247
|
<h3 className="text-foreground text-sm font-medium">Site-wide defaults</h3>
|
|
248
248
|
</header>
|
|
@@ -333,7 +333,7 @@ export function SEOConfigPanel() {
|
|
|
333
333
|
|
|
334
334
|
{/* Per-collection section */}
|
|
335
335
|
<section className="border-border bg-card rounded-lg border">
|
|
336
|
-
<header className="border-border flex items-center gap-2 border-b px-
|
|
336
|
+
<header className="border-border flex items-center gap-2 border-b px-6 py-4">
|
|
337
337
|
<FileText className="text-primary h-4 w-4" />
|
|
338
338
|
<h3 className="text-foreground text-sm font-medium">Per-collection defaults</h3>
|
|
339
339
|
<span className="text-muted-foreground text-xs">
|
|
@@ -142,7 +142,7 @@ export function SchedulePublishDialog({
|
|
|
142
142
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
143
143
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
|
144
144
|
<Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
|
|
145
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
145
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
146
146
|
<div className="flex items-center gap-2">
|
|
147
147
|
<Calendar className="text-muted-foreground h-5 w-5" aria-hidden />
|
|
148
148
|
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
@@ -129,7 +129,7 @@ export function SharePreviewLinkDialog({
|
|
|
129
129
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
130
130
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
|
131
131
|
<Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
|
|
132
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
132
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
133
133
|
<div className="flex items-center gap-2">
|
|
134
134
|
<Share2 className="text-muted-foreground h-5 w-5" aria-hidden />
|
|
135
135
|
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
} from '@actuate-media/cms-core/appearance'
|
|
9
9
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
|
10
10
|
|
|
11
|
+
import { getAdminRootElement } from '../lib/admin-root-element.js'
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* Admin appearance engine.
|
|
13
15
|
*
|
|
@@ -119,7 +121,7 @@ const KEY_MOTION = 'actuate-reduce-motion'
|
|
|
119
121
|
const KEY_BRAND = 'actuate-admin-brand'
|
|
120
122
|
|
|
121
123
|
function adminRootEl(): HTMLElement | null {
|
|
122
|
-
return
|
|
124
|
+
return getAdminRootElement()
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
function applyColorMode(resolved: 'light' | 'dark') {
|
|
@@ -98,7 +98,7 @@ export function VersionHistory({
|
|
|
98
98
|
aria-describedby={undefined}
|
|
99
99
|
className="bg-card text-card-foreground border-border animate-in slide-in-from-right fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l shadow-xl focus:outline-none"
|
|
100
100
|
>
|
|
101
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
101
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
102
102
|
<div className="flex items-center gap-2">
|
|
103
103
|
<Clock className="text-muted-foreground h-5 w-5" aria-hidden />
|
|
104
104
|
<Dialog.Title className="text-foreground text-lg font-medium">
|