@actuate-media/cms-admin 0.76.0 → 0.76.2
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 +50 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.d.ts +2 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js +100 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-plugin-nav.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-plugin-nav.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-plugin-nav.test.js +46 -0
- package/dist/__tests__/layout/sidebar-plugin-nav.test.js.map +1 -0
- package/dist/__tests__/router/navigation-blocker.test.d.ts +2 -0
- package/dist/__tests__/router/navigation-blocker.test.d.ts.map +1 -0
- package/dist/__tests__/router/navigation-blocker.test.js +69 -0
- package/dist/__tests__/router/navigation-blocker.test.js.map +1 -0
- package/dist/__tests__/views/article-body-section.render.test.d.ts +2 -0
- package/dist/__tests__/views/article-body-section.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/article-body-section.render.test.js +24 -0
- package/dist/__tests__/views/article-body-section.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +16 -13
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +17 -0
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/router/index.d.ts +13 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +49 -0
- package/dist/router/index.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +4 -9
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +3 -0
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -11
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +3 -11
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostTemplateEditor.js +3 -10
- package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +112 -0
- package/src/__tests__/layout/sidebar-plugin-nav.test.ts +50 -0
- package/src/__tests__/router/navigation-blocker.test.ts +80 -0
- package/src/__tests__/views/article-body-section.render.test.tsx +32 -0
- package/src/components/seo/SeoEditorPane.tsx +24 -14
- package/src/layout/Sidebar.tsx +19 -0
- package/src/router/index.ts +61 -0
- package/src/styles/theme.css +91 -0
- package/src/views/DocumentEdit.tsx +4 -8
- package/src/views/page-builder/PageBuilder.tsx +4 -0
- package/src/views/page-editor/PageSectionEditor.tsx +3 -10
- package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -4
- package/src/views/post-editor/PostSectionEditor.tsx +3 -10
- package/src/views/post-editor/PostTemplateEditor.tsx +3 -9
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { ArticleBodySection } from '../../views/page-editor/sections/ArticleBodySection.js'
|
|
6
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
7
|
+
|
|
8
|
+
function section(content: Record<string, unknown> = {}): PageSection {
|
|
9
|
+
return {
|
|
10
|
+
id: 's1',
|
|
11
|
+
sectionType: 'article-body',
|
|
12
|
+
name: 'Article Body',
|
|
13
|
+
visible: true,
|
|
14
|
+
content,
|
|
15
|
+
settings: {},
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('ArticleBodySection', () => {
|
|
20
|
+
it('renders post body HTML with rich-text preview class', () => {
|
|
21
|
+
const { container } = render(
|
|
22
|
+
<ArticleBodySection
|
|
23
|
+
section={section({ source: 'field' })}
|
|
24
|
+
context={{ body: '<h2>What is Lorem Ipsum?</h2><p>Body copy.</p>' }}
|
|
25
|
+
/>,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
expect(screen.getByRole('heading', { level: 2, name: 'What is Lorem Ipsum?' })).toBeTruthy()
|
|
29
|
+
expect(container.querySelector('.actuate-rich-text')).toBeTruthy()
|
|
30
|
+
expect(container.querySelector('.prose')).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -269,20 +269,18 @@ export function SeoEditorPane({
|
|
|
269
269
|
return
|
|
270
270
|
}
|
|
271
271
|
if (result.text) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
272
|
+
// Write the suggestion into the field the editor actually asked to
|
|
273
|
+
// generate. Previously an OG title/description generate was remapped
|
|
274
|
+
// onto metaTitle/metaDescription (and the OG field cleared), silently
|
|
275
|
+
// destroying a hand-written search title with no undo. Social fields
|
|
276
|
+
// still inherit meta at render time when left blank, so filling the OG
|
|
277
|
+
// field explicitly is non-destructive and matches the button's intent.
|
|
278
|
+
const patch: Partial<SeoFieldPayload> = { [field]: result.text }
|
|
277
279
|
update(patch)
|
|
278
280
|
if (result.brandAlignment) {
|
|
279
|
-
setAiAlignments((prev) => ({ ...prev, [
|
|
281
|
+
setAiAlignments((prev) => ({ ...prev, [field]: result.brandAlignment }))
|
|
280
282
|
}
|
|
281
|
-
toast.success(
|
|
282
|
-
field === 'ogTitle' || field === 'ogDescription'
|
|
283
|
-
? 'AI suggestion applied to meta tags — social fields inherit unless overridden.'
|
|
284
|
-
: 'AI suggestion applied — review before saving.',
|
|
285
|
-
)
|
|
283
|
+
toast.success('AI suggestion applied — review before saving.')
|
|
286
284
|
} else {
|
|
287
285
|
toast.error('No suggestion was returned.')
|
|
288
286
|
}
|
|
@@ -381,7 +379,11 @@ export function SeoEditorPane({
|
|
|
381
379
|
title: record?.title ?? draftMeta?.seoTitle ?? '',
|
|
382
380
|
slug: slug || record?.url?.split('/').filter(Boolean).pop() || '',
|
|
383
381
|
content: contentHtml,
|
|
384
|
-
|
|
382
|
+
// Measure the FULL rendered title (site template applied), matching what
|
|
383
|
+
// the page score (`calculatePageSeoScore`) and the server /seo/analysis
|
|
384
|
+
// endpoint measure — otherwise the meta-title-length check gives the
|
|
385
|
+
// editor advice that contradicts the list score for the same field.
|
|
386
|
+
metaTitle: renderedTitle,
|
|
385
387
|
metaDescription: form.metaDescription,
|
|
386
388
|
focusKeyphrase: form.focusKeyword,
|
|
387
389
|
canonical: form.canonicalUrl,
|
|
@@ -389,7 +391,7 @@ export function SeoEditorPane({
|
|
|
389
391
|
ogDescription: form.ogDescription,
|
|
390
392
|
ogImage: record?.ogImage ?? undefined,
|
|
391
393
|
}),
|
|
392
|
-
[record, draftMeta, slug, contentHtml, form],
|
|
394
|
+
[record, draftMeta, slug, contentHtml, form, renderedTitle],
|
|
393
395
|
)
|
|
394
396
|
|
|
395
397
|
const liveAnalysis = useMemo(() => {
|
|
@@ -474,7 +476,15 @@ export function SeoEditorPane({
|
|
|
474
476
|
>
|
|
475
477
|
<span>
|
|
476
478
|
SEO score: <span className="font-medium">{record.seoScore}</span> (
|
|
477
|
-
{meta.label})
|
|
479
|
+
{meta.label})
|
|
480
|
+
{analysis && (
|
|
481
|
+
<>
|
|
482
|
+
{' · '}
|
|
483
|
+
<span title="Content quality from the analysis checklist below (readability, keyphrase, links). Separate from the meta-weighted SEO score used to rank this list.">
|
|
484
|
+
Content analysis {analysis.score}/100
|
|
485
|
+
</span>
|
|
486
|
+
</>
|
|
487
|
+
)}
|
|
478
488
|
</span>
|
|
479
489
|
</div>
|
|
480
490
|
)
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -876,6 +876,25 @@ export function buildNavItems(config: any): NavItem[] {
|
|
|
876
876
|
}
|
|
877
877
|
for (const bucket of groupBuckets.values()) items.push(...bucket)
|
|
878
878
|
|
|
879
|
+
// Plugin-contributed custom nav links (PluginDefinition.adminNavItems,
|
|
880
|
+
// collected onto `_pluginNavItems` by cms-core's applyPlugins). Deduped by
|
|
881
|
+
// path against everything already present so a plugin can't double a built-in
|
|
882
|
+
// destination or one of its own collections.
|
|
883
|
+
const pluginNav: Array<{ label?: string; icon?: string; path?: string }> = Array.isArray(
|
|
884
|
+
config?._pluginNavItems,
|
|
885
|
+
)
|
|
886
|
+
? config._pluginNavItems
|
|
887
|
+
: []
|
|
888
|
+
for (const item of pluginNav) {
|
|
889
|
+
if (!item?.path || items.some((n) => n.path === item.path)) continue
|
|
890
|
+
items.push({
|
|
891
|
+
label: item.label ?? item.path,
|
|
892
|
+
path: item.path,
|
|
893
|
+
icon: (item.icon && ICON_MAP[item.icon]) || FileText,
|
|
894
|
+
group: 'Content',
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
|
|
879
898
|
// Settings destinations — always last. Users + API Keys now live inside
|
|
880
899
|
// the Settings view as tabs (see Settings.tsx), so only Settings is anchored
|
|
881
900
|
// here.
|
package/src/router/index.ts
CHANGED
|
@@ -23,6 +23,61 @@ export function parseRouteQuery(path: string): URLSearchParams {
|
|
|
23
23
|
return new URLSearchParams(path.split('#')[0]!.split('?')[1] ?? '')
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Confirmation shown when navigating away from an editor with unsaved changes. */
|
|
27
|
+
export const NAVIGATION_CONFIRM_MESSAGE =
|
|
28
|
+
'You have unsaved changes that will be lost. Leave this page anyway?'
|
|
29
|
+
|
|
30
|
+
// Module-level registry of "is there unsaved work?" predicates. Editors register
|
|
31
|
+
// via useNavigationBlocker; the SPA router's navigate() consults them before an
|
|
32
|
+
// in-app route change so Cancel / sidebar / breadcrumb / command-palette links
|
|
33
|
+
// can't silently discard an editor's unsaved changes (beforeunload only covers
|
|
34
|
+
// hard reloads, never client-side navigation).
|
|
35
|
+
type BlockerPredicate = () => boolean
|
|
36
|
+
const navigationBlockers = new Set<BlockerPredicate>()
|
|
37
|
+
|
|
38
|
+
export function registerNavigationBlocker(predicate: BlockerPredicate): () => void {
|
|
39
|
+
navigationBlockers.add(predicate)
|
|
40
|
+
return () => {
|
|
41
|
+
navigationBlockers.delete(predicate)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when any registered editor currently has unsaved changes. */
|
|
46
|
+
export function hasBlockingNavigation(): boolean {
|
|
47
|
+
for (const predicate of navigationBlockers) {
|
|
48
|
+
try {
|
|
49
|
+
if (predicate()) return true
|
|
50
|
+
} catch {
|
|
51
|
+
// A misbehaving predicate must not wedge navigation.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register an unsaved-changes guard for an editor. While `isDirty` is true, an
|
|
59
|
+
* in-app navigation prompts for confirmation (via navigate()) and a browser
|
|
60
|
+
* unload/refresh shows the native "leave site?" prompt. The registration and
|
|
61
|
+
* the beforeunload listener are torn down on unmount.
|
|
62
|
+
*/
|
|
63
|
+
export function useNavigationBlocker(isDirty: boolean): void {
|
|
64
|
+
const dirtyRef = useRef(isDirty)
|
|
65
|
+
dirtyRef.current = isDirty
|
|
66
|
+
|
|
67
|
+
useEffect(() => registerNavigationBlocker(() => dirtyRef.current), [])
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!isDirty || typeof window === 'undefined') return
|
|
71
|
+
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
// Required by Chrome/Safari to actually trigger the native prompt.
|
|
74
|
+
e.returnValue = ''
|
|
75
|
+
}
|
|
76
|
+
window.addEventListener('beforeunload', onBeforeUnload)
|
|
77
|
+
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
|
78
|
+
}, [isDirty])
|
|
79
|
+
}
|
|
80
|
+
|
|
26
81
|
export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
27
82
|
const [currentPath, setCurrentPath] = useState(serverPath)
|
|
28
83
|
const baseRef = useRef(basePath)
|
|
@@ -52,6 +107,12 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
|
52
107
|
|
|
53
108
|
const navigate = useCallback((path: string) => {
|
|
54
109
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
|
110
|
+
|
|
111
|
+
// Guard against discarding unsaved editor work on client-side navigation.
|
|
112
|
+
if (hasBlockingNavigation() && typeof window !== 'undefined') {
|
|
113
|
+
if (!window.confirm(NAVIGATION_CONFIRM_MESSAGE)) return
|
|
114
|
+
}
|
|
115
|
+
|
|
55
116
|
setCurrentPath(normalizedPath)
|
|
56
117
|
|
|
57
118
|
const fullUrl = normalizedPath === '/' ? baseRef.current : `${baseRef.current}${normalizedPath}`
|
package/src/styles/theme.css
CHANGED
|
@@ -224,6 +224,97 @@
|
|
|
224
224
|
letter-spacing: revert-layer;
|
|
225
225
|
line-height: revert-layer;
|
|
226
226
|
}
|
|
227
|
+
|
|
228
|
+
/*
|
|
229
|
+
* Rich-text HTML in the canvas preview (article body, etc.). The TipTap
|
|
230
|
+
* editor styles headings in `.ProseMirror`; the canvas cannot rely on
|
|
231
|
+
* `prose` because @tailwindcss/typography is not bundled, and the revert
|
|
232
|
+
* above strips admin heading scales without substituting site typography.
|
|
233
|
+
*/
|
|
234
|
+
.actuate-admin .actuate-canvas .actuate-rich-text {
|
|
235
|
+
color: var(--foreground);
|
|
236
|
+
font-size: 1rem;
|
|
237
|
+
line-height: 1.625;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.actuate-admin .actuate-canvas .actuate-rich-text :is(h1, h2, h3, h4, h5, h6) {
|
|
241
|
+
color: var(--foreground);
|
|
242
|
+
font-weight: 600;
|
|
243
|
+
line-height: 1.5;
|
|
244
|
+
margin-top: 1.25em;
|
|
245
|
+
margin-bottom: 0.5em;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.actuate-admin .actuate-canvas .actuate-rich-text h1 {
|
|
249
|
+
font-size: 2em;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.actuate-admin .actuate-canvas .actuate-rich-text h2 {
|
|
253
|
+
font-size: 1.5em;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.actuate-admin .actuate-canvas .actuate-rich-text h3 {
|
|
257
|
+
font-size: 1.25em;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.actuate-admin .actuate-canvas .actuate-rich-text h4 {
|
|
261
|
+
font-size: 1.125em;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.actuate-admin .actuate-canvas .actuate-rich-text h5 {
|
|
265
|
+
font-size: 1em;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.actuate-admin .actuate-canvas .actuate-rich-text h6 {
|
|
269
|
+
font-size: 0.875em;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.actuate-admin .actuate-canvas .actuate-rich-text p {
|
|
273
|
+
margin-top: 0.75em;
|
|
274
|
+
margin-bottom: 0.75em;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.actuate-admin .actuate-canvas .actuate-rich-text :is(ul, ol) {
|
|
278
|
+
margin-top: 0.75em;
|
|
279
|
+
margin-bottom: 0.75em;
|
|
280
|
+
padding-left: 1.5rem;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.actuate-admin .actuate-canvas .actuate-rich-text ul {
|
|
284
|
+
list-style-type: disc;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.actuate-admin .actuate-canvas .actuate-rich-text ol {
|
|
288
|
+
list-style-type: decimal;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.actuate-admin .actuate-canvas .actuate-rich-text li {
|
|
292
|
+
margin-top: 0.25em;
|
|
293
|
+
margin-bottom: 0.25em;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.actuate-admin .actuate-canvas .actuate-rich-text a {
|
|
297
|
+
color: var(--primary);
|
|
298
|
+
text-decoration: underline;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.actuate-admin .actuate-canvas .actuate-rich-text :is(strong, b) {
|
|
302
|
+
font-weight: 600;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.actuate-admin .actuate-canvas .actuate-rich-text blockquote {
|
|
306
|
+
border-left: 3px solid var(--border);
|
|
307
|
+
margin: 1em 0;
|
|
308
|
+
padding-left: 1em;
|
|
309
|
+
color: var(--muted-foreground);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.actuate-admin .actuate-canvas .actuate-rich-text img {
|
|
313
|
+
max-width: 100%;
|
|
314
|
+
height: auto;
|
|
315
|
+
border-radius: var(--radius-md);
|
|
316
|
+
margin: 1em 0;
|
|
317
|
+
}
|
|
227
318
|
}
|
|
228
319
|
|
|
229
320
|
.actuate-admin.reduce-motion,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
XCircle,
|
|
15
15
|
} from 'lucide-react'
|
|
16
16
|
import { toast } from 'sonner'
|
|
17
|
+
import { useNavigationBlocker } from '../router/index.js'
|
|
17
18
|
import { FieldRenderer } from '../fields/FieldRenderer.js'
|
|
18
19
|
import { RelationshipField } from '../fields/RelationshipField.js'
|
|
19
20
|
import { Button } from '../components/ui/Button.js'
|
|
@@ -101,14 +102,9 @@ export function DocumentEdit({
|
|
|
101
102
|
}
|
|
102
103
|
}, [documentId, isNew])
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
e.preventDefault()
|
|
108
|
-
}
|
|
109
|
-
window.addEventListener('beforeunload', handler)
|
|
110
|
-
return () => window.removeEventListener('beforeunload', handler)
|
|
111
|
-
}, [isDirty])
|
|
105
|
+
// Warn before discarding unsaved changes on client-side navigation (Cancel,
|
|
106
|
+
// sidebar/breadcrumb links) and on browser unload/refresh.
|
|
107
|
+
useNavigationBlocker(isDirty)
|
|
112
108
|
|
|
113
109
|
const SEO_FIELDS: (keyof SEOData)[] = [
|
|
114
110
|
'metaTitle',
|
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
|
|
4
4
|
import { Loader2, AlertTriangle } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { cmsApi } from '../../lib/api.js'
|
|
7
|
+
import { useNavigationBlocker } from '../../router/index.js'
|
|
7
8
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
8
9
|
import {
|
|
9
10
|
useBuilderState,
|
|
@@ -169,6 +170,9 @@ export function PageBuilder({
|
|
|
169
170
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- replaceTree/setPageSettings are stable builder actions
|
|
170
171
|
}, [documentId, templateId, collectionSlug])
|
|
171
172
|
|
|
173
|
+
// Warn before discarding unsaved builder changes on navigation / unload.
|
|
174
|
+
useNavigationBlocker(dirty)
|
|
175
|
+
|
|
172
176
|
const handleSave = useCallback(async () => {
|
|
173
177
|
setSaving(true)
|
|
174
178
|
const body = JSON.stringify({
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
4
4
|
import { AlertTriangle, Blocks, Loader2, Lock } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
7
|
+
import { useNavigationBlocker } from '../../router/index.js'
|
|
7
8
|
import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
|
|
8
9
|
import { EditorSeoAside } from '../../components/EditorSeoAside.js'
|
|
9
10
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
@@ -190,16 +191,8 @@ export function PageSectionEditor({
|
|
|
190
191
|
}
|
|
191
192
|
}, [documentId, initialParentId, schemaError])
|
|
192
193
|
|
|
193
|
-
// ─── Unsaved-changes guard (browser unload)
|
|
194
|
-
|
|
195
|
-
if (!dirty) return
|
|
196
|
-
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
197
|
-
e.preventDefault()
|
|
198
|
-
e.returnValue = ''
|
|
199
|
-
}
|
|
200
|
-
window.addEventListener('beforeunload', onBeforeUnload)
|
|
201
|
-
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
|
202
|
-
}, [dirty])
|
|
194
|
+
// ─── Unsaved-changes guard (client-side navigation + browser unload) ──
|
|
195
|
+
useNavigationBlocker(dirty)
|
|
203
196
|
|
|
204
197
|
// ─── Section mutations ───────────────────────────────────────────────
|
|
205
198
|
const mutateSections = useCallback(
|
|
@@ -37,10 +37,7 @@ export function ArticleBodySection({
|
|
|
37
37
|
</div>
|
|
38
38
|
)}
|
|
39
39
|
{html.trim() ? (
|
|
40
|
-
<div
|
|
41
|
-
className="prose prose-zinc text-foreground max-w-none text-base leading-relaxed"
|
|
42
|
-
dangerouslySetInnerHTML={{ __html: html }}
|
|
43
|
-
/>
|
|
40
|
+
<div className="actuate-rich-text max-w-none" dangerouslySetInnerHTML={{ __html: html }} />
|
|
44
41
|
) : (
|
|
45
42
|
<p className="text-muted-foreground text-base leading-relaxed italic">
|
|
46
43
|
{source === 'field'
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
4
4
|
import { AlertTriangle, Loader2, Lock } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
7
|
+
import { useNavigationBlocker } from '../../router/index.js'
|
|
7
8
|
import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
|
|
8
9
|
import { EditorSeoAside } from '../../components/EditorSeoAside.js'
|
|
9
10
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
@@ -227,16 +228,8 @@ export function PostSectionEditor({
|
|
|
227
228
|
}
|
|
228
229
|
}, [postType, documentId, schemaError])
|
|
229
230
|
|
|
230
|
-
// ─── Unsaved-changes guard
|
|
231
|
-
|
|
232
|
-
if (!dirty) return
|
|
233
|
-
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
234
|
-
e.preventDefault()
|
|
235
|
-
e.returnValue = ''
|
|
236
|
-
}
|
|
237
|
-
window.addEventListener('beforeunload', onBeforeUnload)
|
|
238
|
-
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
|
239
|
-
}, [dirty])
|
|
231
|
+
// ─── Unsaved-changes guard (client-side navigation + browser unload) ──
|
|
232
|
+
useNavigationBlocker(dirty)
|
|
240
233
|
|
|
241
234
|
const context: PostRenderContext = useMemo(
|
|
242
235
|
() => ({
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
4
4
|
import { AlertTriangle, ArrowLeft, Layout, Loader2, Save } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
7
|
+
import { useNavigationBlocker } from '../../router/index.js'
|
|
7
8
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
8
9
|
import {
|
|
9
10
|
addSection,
|
|
@@ -99,15 +100,8 @@ export function PostTemplateEditor({
|
|
|
99
100
|
}
|
|
100
101
|
}, [postType])
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
105
|
-
e.preventDefault()
|
|
106
|
-
e.returnValue = ''
|
|
107
|
-
}
|
|
108
|
-
window.addEventListener('beforeunload', onBeforeUnload)
|
|
109
|
-
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
|
110
|
-
}, [dirty])
|
|
103
|
+
// Unsaved-changes guard (client-side navigation + browser unload).
|
|
104
|
+
useNavigationBlocker(dirty)
|
|
111
105
|
|
|
112
106
|
const mutateSections = useCallback(
|
|
113
107
|
(fn: (sections: EditorPostTemplate['sections']) => EditorPostTemplate['sections']) => {
|