@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/__tests__/components/seo-editor-pane.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/seo-editor-pane.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/seo-editor-pane.render.test.js +100 -0
  5. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -0
  6. package/dist/__tests__/layout/sidebar-plugin-nav.test.d.ts +2 -0
  7. package/dist/__tests__/layout/sidebar-plugin-nav.test.d.ts.map +1 -0
  8. package/dist/__tests__/layout/sidebar-plugin-nav.test.js +46 -0
  9. package/dist/__tests__/layout/sidebar-plugin-nav.test.js.map +1 -0
  10. package/dist/__tests__/router/navigation-blocker.test.d.ts +2 -0
  11. package/dist/__tests__/router/navigation-blocker.test.d.ts.map +1 -0
  12. package/dist/__tests__/router/navigation-blocker.test.js +69 -0
  13. package/dist/__tests__/router/navigation-blocker.test.js.map +1 -0
  14. package/dist/__tests__/views/article-body-section.render.test.d.ts +2 -0
  15. package/dist/__tests__/views/article-body-section.render.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/article-body-section.render.test.js +24 -0
  17. package/dist/__tests__/views/article-body-section.render.test.js.map +1 -0
  18. package/dist/actuate-admin.css +1 -1
  19. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  20. package/dist/components/seo/SeoEditorPane.js +16 -13
  21. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  22. package/dist/layout/Sidebar.d.ts.map +1 -1
  23. package/dist/layout/Sidebar.js +17 -0
  24. package/dist/layout/Sidebar.js.map +1 -1
  25. package/dist/router/index.d.ts +13 -0
  26. package/dist/router/index.d.ts.map +1 -1
  27. package/dist/router/index.js +49 -0
  28. package/dist/router/index.js.map +1 -1
  29. package/dist/views/DocumentEdit.d.ts.map +1 -1
  30. package/dist/views/DocumentEdit.js +4 -9
  31. package/dist/views/DocumentEdit.js.map +1 -1
  32. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  33. package/dist/views/page-builder/PageBuilder.js +3 -0
  34. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  35. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  36. package/dist/views/page-editor/PageSectionEditor.js +3 -11
  37. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  38. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  39. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  40. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  41. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  42. package/dist/views/post-editor/PostSectionEditor.js +3 -11
  43. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  44. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -1
  45. package/dist/views/post-editor/PostTemplateEditor.js +3 -10
  46. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  47. package/package.json +4 -3
  48. package/src/__tests__/components/seo-editor-pane.render.test.tsx +112 -0
  49. package/src/__tests__/layout/sidebar-plugin-nav.test.ts +50 -0
  50. package/src/__tests__/router/navigation-blocker.test.ts +80 -0
  51. package/src/__tests__/views/article-body-section.render.test.tsx +32 -0
  52. package/src/components/seo/SeoEditorPane.tsx +24 -14
  53. package/src/layout/Sidebar.tsx +19 -0
  54. package/src/router/index.ts +61 -0
  55. package/src/styles/theme.css +91 -0
  56. package/src/views/DocumentEdit.tsx +4 -8
  57. package/src/views/page-builder/PageBuilder.tsx +4 -0
  58. package/src/views/page-editor/PageSectionEditor.tsx +3 -10
  59. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -4
  60. package/src/views/post-editor/PostSectionEditor.tsx +3 -10
  61. 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
- const targetField =
273
- field === 'ogTitle' ? 'metaTitle' : field === 'ogDescription' ? 'metaDescription' : field
274
- const patch: Partial<SeoFieldPayload> = { [targetField]: result.text }
275
- if (field === 'ogTitle') patch.ogTitle = ''
276
- if (field === 'ogDescription') patch.ogDescription = ''
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, [targetField]: result.brandAlignment }))
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
- metaTitle: form.metaTitle,
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}){analysis && ` · Live analysis ${analysis.score}`}
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
  )
@@ -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.
@@ -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}`
@@ -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
- useEffect(() => {
105
- if (!isDirty) return
106
- const handler = (e: BeforeUnloadEvent) => {
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
- useEffect(() => {
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
- useEffect(() => {
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
- useEffect(() => {
103
- if (!dirty) return
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']) => {