@prenta/admin 2.0.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +2 -6
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/document-locale-switcher.test.d.ts +2 -0
  6. package/dist/__tests__/components/document-locale-switcher.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/document-locale-switcher.test.js +78 -0
  8. package/dist/__tests__/components/document-locale-switcher.test.js.map +1 -0
  9. package/dist/__tests__/lib/admin-shortcuts.test.d.ts +2 -0
  10. package/dist/__tests__/lib/admin-shortcuts.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/admin-shortcuts.test.js +11 -0
  12. package/dist/__tests__/lib/admin-shortcuts.test.js.map +1 -0
  13. package/dist/__tests__/lib/editor-history.test.d.ts +2 -0
  14. package/dist/__tests__/lib/editor-history.test.d.ts.map +1 -0
  15. package/dist/__tests__/lib/editor-history.test.js +76 -0
  16. package/dist/__tests__/lib/editor-history.test.js.map +1 -0
  17. package/dist/__tests__/views/editor-top-bar.render.test.js +17 -0
  18. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  19. package/dist/__tests__/views/post-section-editor.test.js +16 -0
  20. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  21. package/dist/components/DocumentLocaleSwitcher.d.ts +15 -0
  22. package/dist/components/DocumentLocaleSwitcher.d.ts.map +1 -0
  23. package/dist/components/DocumentLocaleSwitcher.js +66 -0
  24. package/dist/components/DocumentLocaleSwitcher.js.map +1 -0
  25. package/dist/hooks/use-editor-history.d.ts +19 -0
  26. package/dist/hooks/use-editor-history.d.ts.map +1 -0
  27. package/dist/hooks/use-editor-history.js +94 -0
  28. package/dist/hooks/use-editor-history.js.map +1 -0
  29. package/dist/lib/admin-shortcuts.d.ts +6 -0
  30. package/dist/lib/admin-shortcuts.d.ts.map +1 -0
  31. package/dist/lib/admin-shortcuts.js +11 -0
  32. package/dist/lib/admin-shortcuts.js.map +1 -0
  33. package/dist/lib/editor-history.d.ts +24 -0
  34. package/dist/lib/editor-history.d.ts.map +1 -0
  35. package/dist/lib/editor-history.js +61 -0
  36. package/dist/lib/editor-history.js.map +1 -0
  37. package/dist/prenta-admin.css +1 -1
  38. package/dist/views/page-editor/EditorTopBar.d.ts +9 -1
  39. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  40. package/dist/views/page-editor/EditorTopBar.js +3 -3
  41. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  42. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  43. package/dist/views/page-editor/PageSectionEditor.js +38 -9
  44. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  45. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  46. package/dist/views/post-editor/PostSectionEditor.js +45 -11
  47. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  48. package/package.json +4 -4
  49. package/src/AdminRoot.tsx +5 -9
  50. package/src/__tests__/components/document-locale-switcher.test.tsx +111 -0
  51. package/src/__tests__/lib/admin-shortcuts.test.ts +11 -0
  52. package/src/__tests__/lib/editor-history.test.ts +99 -0
  53. package/src/__tests__/views/editor-top-bar.render.test.tsx +27 -0
  54. package/src/__tests__/views/post-section-editor.test.tsx +30 -0
  55. package/src/components/DocumentLocaleSwitcher.tsx +140 -0
  56. package/src/hooks/use-editor-history.ts +102 -0
  57. package/src/lib/admin-shortcuts.ts +10 -0
  58. package/src/lib/editor-history.ts +83 -0
  59. package/src/views/page-editor/EditorTopBar.tsx +39 -0
  60. package/src/views/page-editor/PageSectionEditor.tsx +65 -10
  61. package/src/views/post-editor/PostSectionEditor.tsx +76 -14
@@ -0,0 +1,102 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import {
5
+ createEditorHistory,
6
+ isNativeEditingTarget,
7
+ pushEditorHistory,
8
+ redoEditorHistory,
9
+ undoEditorHistory,
10
+ } from '../lib/editor-history.js'
11
+
12
+ function cloneSnapshot<T>(value: T): T {
13
+ return JSON.parse(JSON.stringify(value)) as T
14
+ }
15
+
16
+ /**
17
+ * Snapshot stack for the section editor. `note` records the document from
18
+ * before a change and is safe to call inside a state updater (it only writes
19
+ * a ref). The stack commits after that render. Rapid notes with the same key
20
+ * collapse into one step.
21
+ */
22
+ export function useEditorHistory<T>(current: T | null, resetKey: string | null) {
23
+ const historyRef = useRef(createEditorHistory<T>())
24
+ const pendingRef = useRef<Array<{ snapshot: T; key: string; coalesce: boolean }>>([])
25
+ const [, setRevision] = useState(0)
26
+
27
+ const note = useCallback((snapshot: T, key: string, coalesce = false) => {
28
+ pendingRef.current.push({ snapshot: cloneSnapshot(snapshot), key, coalesce })
29
+ }, [])
30
+
31
+ useEffect(() => {
32
+ historyRef.current = createEditorHistory()
33
+ pendingRef.current = []
34
+ setRevision((n) => n + 1)
35
+ }, [resetKey])
36
+
37
+ useEffect(() => {
38
+ if (pendingRef.current.length === 0) return
39
+ const now = Date.now()
40
+ let next = historyRef.current
41
+ for (const item of pendingRef.current) {
42
+ next = pushEditorHistory(next, item.snapshot, {
43
+ key: item.key,
44
+ now,
45
+ coalesce: item.coalesce,
46
+ })
47
+ }
48
+ pendingRef.current = []
49
+ historyRef.current = next
50
+ setRevision((n) => n + 1)
51
+ }, [current])
52
+
53
+ const undo = useCallback((): T | null => {
54
+ if (current == null) return null
55
+ const result = undoEditorHistory(historyRef.current, cloneSnapshot(current))
56
+ if (!result) return null
57
+ historyRef.current = result.history
58
+ setRevision((n) => n + 1)
59
+ return result.value
60
+ }, [current])
61
+
62
+ const redo = useCallback((): T | null => {
63
+ if (current == null) return null
64
+ const result = redoEditorHistory(historyRef.current, cloneSnapshot(current))
65
+ if (!result) return null
66
+ historyRef.current = result.history
67
+ setRevision((n) => n + 1)
68
+ return result.value
69
+ }, [current])
70
+
71
+ return {
72
+ note,
73
+ undo,
74
+ redo,
75
+ canUndo: historyRef.current.past.length > 0,
76
+ canRedo: historyRef.current.future.length > 0,
77
+ }
78
+ }
79
+
80
+ export function useSectionEditorShortcuts(input: {
81
+ enabled: boolean
82
+ onUndo: () => void
83
+ onRedo: () => void
84
+ }) {
85
+ const { enabled, onUndo, onRedo } = input
86
+ useEffect(() => {
87
+ if (!enabled) return
88
+ function onKey(event: KeyboardEvent) {
89
+ if (isNativeEditingTarget(event.target)) return
90
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return
91
+ const key = event.key.toLowerCase()
92
+ const redoKey = key === 'y' || (key === 'z' && event.shiftKey)
93
+ const undoKey = key === 'z' && !event.shiftKey
94
+ if (!undoKey && !redoKey) return
95
+ event.preventDefault()
96
+ if (redoKey) onRedo()
97
+ else onUndo()
98
+ }
99
+ document.addEventListener('keydown', onKey)
100
+ return () => document.removeEventListener('keydown', onKey)
101
+ }, [enabled, onUndo, onRedo])
102
+ }
@@ -0,0 +1,10 @@
1
+ /** Shown in the keyboard-shortcuts dialog (⌘ /). */
2
+ export const ADMIN_KEYBOARD_SHORTCUTS: ReadonlyArray<{ keys: string; label: string }> = [
3
+ { keys: '⌘ K', label: 'Open search' },
4
+ { keys: '⌘ S', label: 'Save document' },
5
+ { keys: '⌘ Z', label: 'Undo in the section editor' },
6
+ { keys: '⌘ ⇧ Z', label: 'Redo in the section editor' },
7
+ { keys: '⌘ Y', label: 'Redo in the section editor' },
8
+ { keys: '⌘ /', label: 'Toggle this help' },
9
+ { keys: 'Esc', label: 'Close panel / modal' },
10
+ ]
@@ -0,0 +1,83 @@
1
+ /** How long edits with the same key collapse into one undo step. */
2
+ const EDITOR_HISTORY_COALESCE_MS = 800
3
+
4
+ /** Cap so a long editing session cannot grow the stack without bound. */
5
+ const EDITOR_HISTORY_LIMIT = 50
6
+
7
+ interface EditorHistory<T> {
8
+ past: T[]
9
+ future: T[]
10
+ lastKey: string | null
11
+ lastAt: number
12
+ }
13
+
14
+ export function createEditorHistory<T>(): EditorHistory<T> {
15
+ return { past: [], future: [], lastKey: null, lastAt: 0 }
16
+ }
17
+
18
+ export function pushEditorHistory<T>(
19
+ history: EditorHistory<T>,
20
+ snapshot: T,
21
+ options: { key: string; now: number; coalesce?: boolean },
22
+ ): EditorHistory<T> {
23
+ const coalesce = options.coalesce === true
24
+ if (
25
+ coalesce &&
26
+ history.lastKey === options.key &&
27
+ history.past.length > 0 &&
28
+ options.now - history.lastAt < EDITOR_HISTORY_COALESCE_MS
29
+ ) {
30
+ return { ...history, lastAt: options.now }
31
+ }
32
+
33
+ const past = [...history.past, snapshot]
34
+ return {
35
+ past:
36
+ past.length > EDITOR_HISTORY_LIMIT ? past.slice(past.length - EDITOR_HISTORY_LIMIT) : past,
37
+ future: [],
38
+ lastKey: options.key,
39
+ lastAt: options.now,
40
+ }
41
+ }
42
+
43
+ export function undoEditorHistory<T>(
44
+ history: EditorHistory<T>,
45
+ current: T,
46
+ ): { history: EditorHistory<T>; value: T } | null {
47
+ if (history.past.length === 0) return null
48
+ const value = history.past[history.past.length - 1] as T
49
+ return {
50
+ value,
51
+ history: {
52
+ past: history.past.slice(0, -1),
53
+ future: [...history.future, current],
54
+ lastKey: null,
55
+ lastAt: 0,
56
+ },
57
+ }
58
+ }
59
+
60
+ export function redoEditorHistory<T>(
61
+ history: EditorHistory<T>,
62
+ current: T,
63
+ ): { history: EditorHistory<T>; value: T } | null {
64
+ if (history.future.length === 0) return null
65
+ const value = history.future[history.future.length - 1] as T
66
+ return {
67
+ value,
68
+ history: {
69
+ past: [...history.past, current],
70
+ future: history.future.slice(0, -1),
71
+ lastKey: null,
72
+ lastAt: 0,
73
+ },
74
+ }
75
+ }
76
+
77
+ /** Text fields and rich text keep their own undo. The page stack must not steal it. */
78
+ export function isNativeEditingTarget(target: EventTarget | null): boolean {
79
+ if (!(target instanceof HTMLElement)) return false
80
+ if (target.isContentEditable) return true
81
+ const tag = target.tagName
82
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
83
+ }
@@ -9,9 +9,11 @@ import {
9
9
  ExternalLink,
10
10
  History,
11
11
  Loader2,
12
+ Redo2,
12
13
  Settings2,
13
14
  Share2,
14
15
  TriangleAlert,
16
+ Undo2,
15
17
  } from 'lucide-react'
16
18
  import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
17
19
  import { BreakpointPicker } from '../../components/BreakpointPicker.js'
@@ -99,6 +101,13 @@ interface EditorTopBarProps {
99
101
  /** Band for `seoScore`; omit to derive from the content-analysis scale. */
100
102
  seoScoreBand?: 'good' | 'fair' | 'poor'
101
103
  onSaveDraft: () => void
104
+ /** Section-editor undo. Omitted outside the page and post editors. */
105
+ onUndo?: () => void
106
+ onRedo?: () => void
107
+ canUndo?: boolean
108
+ canRedo?: boolean
109
+ /** Locale switcher for this document. Hidden by the caller when unused. */
110
+ localeControl?: ReactNode
102
111
  onSubmitForReview: () => void
103
112
  onApprove: () => void
104
113
  onRequestChanges: (note: string) => void
@@ -149,6 +158,11 @@ export function EditorTopBar({
149
158
  seoScore,
150
159
  seoScoreBand,
151
160
  onSaveDraft,
161
+ onUndo,
162
+ onRedo,
163
+ canUndo = false,
164
+ canRedo = false,
165
+ localeControl,
152
166
  onSubmitForReview,
153
167
  onApprove,
154
168
  onRequestChanges,
@@ -290,6 +304,31 @@ export function EditorTopBar({
290
304
  </span>
291
305
  )}
292
306
  <SaveStatus state={saveState} dirty={dirty} />
307
+ {onUndo && onRedo && (
308
+ <span className="ml-1 inline-flex shrink-0 items-center">
309
+ <button
310
+ type="button"
311
+ onClick={onUndo}
312
+ disabled={!canUndo}
313
+ aria-label="Undo"
314
+ title="Undo the last edit (Ctrl+Z or ⌘Z). Text fields keep their own undo."
315
+ className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
316
+ >
317
+ <Undo2 className="h-4 w-4" aria-hidden />
318
+ </button>
319
+ <button
320
+ type="button"
321
+ onClick={onRedo}
322
+ disabled={!canRedo}
323
+ aria-label="Redo"
324
+ title="Redo (Ctrl+Shift+Z, Ctrl+Y, or ⌘⇧Z)."
325
+ className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
326
+ >
327
+ <Redo2 className="h-4 w-4" aria-hidden />
328
+ </button>
329
+ </span>
330
+ )}
331
+ {localeControl ? <span className="ml-1 inline-flex shrink-0">{localeControl}</span> : null}
293
332
  </div>
294
333
 
295
334
  {/* Right controls */}
@@ -9,9 +9,11 @@ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js
9
9
  import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
10
10
  import { EditorRail, RailHeader } from '../../components/EditorRail.js'
11
11
  import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
12
+ import { DocumentLocaleSwitcher } from '../../components/DocumentLocaleSwitcher.js'
12
13
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
14
  import { VersionHistoryPanel } from './VersionHistoryPanel.js'
14
15
  import { useDebouncedAutosave } from './autosave.js'
16
+ import { useEditorHistory, useSectionEditorShortcuts } from '../../hooks/use-editor-history.js'
15
17
  import { ReviewBar } from './ReviewBar.js'
16
18
  import {
17
19
  fetchPageVersions,
@@ -215,11 +217,14 @@ export function PageSectionEditor({
215
217
  * so a save moves the baseline exactly as a publish does. Missing one would
216
218
  * leave the guard comparing against content that is no longer on the site.
217
219
  */
220
+ const savedJson = useRef('')
218
221
  const acceptPage = useCallback((p: EditorPage) => {
222
+ savedJson.current = JSON.stringify(p)
219
223
  setPage(p)
220
224
  setBaseline(snapshotBaseline(p.sections, p.status))
221
225
  setPublishedPath(p.status === 'PUBLISHED' ? p.path : null)
222
226
  }, [])
227
+ const { note, undo, redo, canUndo, canRedo } = useEditorHistory(page, documentId ?? null)
223
228
  const [dirty, setDirty] = useState(false)
224
229
  const [saveState, setSaveState] = useState<SaveState>('idle')
225
230
  const [publishing, setPublishing] = useState(false)
@@ -486,23 +491,35 @@ export function PageSectionEditor({
486
491
 
487
492
  // ─── Section mutations ───────────────────────────────────────────────
488
493
  const mutateSections = useCallback(
489
- (fn: (sections: EditorPage['sections']) => EditorPage['sections']) => {
490
- setPage((prev) => (prev ? { ...prev, sections: fn(prev.sections) } : prev))
494
+ (
495
+ fn: (sections: EditorPage['sections']) => EditorPage['sections'],
496
+ key: string,
497
+ coalesce = false,
498
+ ) => {
499
+ setPage((prev) => {
500
+ if (!prev) return prev
501
+ note(prev, key, coalesce)
502
+ return { ...prev, sections: fn(prev.sections) }
503
+ })
491
504
  setDirty(true)
492
505
  setSaveState('idle')
493
506
  },
494
- [],
507
+ [note],
495
508
  )
496
509
 
497
510
  const handleToggleVisibility = useCallback(
498
- (id: string) => mutateSections((s) => toggleSectionVisibility(s, id)),
511
+ (id: string) => mutateSections((s) => toggleSectionVisibility(s, id), `visibility:${id}`),
499
512
  [mutateSections],
500
513
  )
501
514
 
502
515
  const handleContentChange = useCallback(
503
516
  (patch: Record<string, unknown>) => {
504
517
  if (!selectedId) return
505
- mutateSections((s) => updateSectionContent(s, selectedId, patch))
518
+ mutateSections(
519
+ (s) => updateSectionContent(s, selectedId, patch),
520
+ `content:${selectedId}`,
521
+ true,
522
+ )
506
523
  },
507
524
  [selectedId, mutateSections],
508
525
  )
@@ -517,18 +534,41 @@ export function PageSectionEditor({
517
534
  const handleMetaChange = useCallback(
518
535
  (patch: { name?: string; internalLabel?: string }) => {
519
536
  if (!selectedId) return
520
- mutateSections((s) => updateSectionMeta(s, selectedId, patch))
537
+ mutateSections((s) => updateSectionMeta(s, selectedId, patch), `meta:${selectedId}`, true)
521
538
  },
522
539
  [selectedId, mutateSections],
523
540
  )
524
541
 
525
- // ─── Page-level settings ─────────────────────────────────────────────
526
- const handlePageSettings = useCallback((patch: Partial<EditorPage>) => {
527
- setPage((prev) => (prev ? { ...prev, ...patch } : prev))
528
- setDirty(true)
542
+ const restoreEdit = useCallback((next: EditorPage | null) => {
543
+ if (!next) return
544
+ setPage(next)
545
+ setDirty(JSON.stringify(next) !== savedJson.current)
529
546
  setSaveState('idle')
530
547
  }, [])
531
548
 
549
+ const handleUndo = useCallback(() => restoreEdit(undo()), [restoreEdit, undo])
550
+ const handleRedo = useCallback(() => restoreEdit(redo()), [restoreEdit, redo])
551
+
552
+ useSectionEditorShortcuts({
553
+ enabled: effectiveCanEdit,
554
+ onUndo: handleUndo,
555
+ onRedo: handleRedo,
556
+ })
557
+
558
+ // ─── Page-level settings ─────────────────────────────────────────────
559
+ const handlePageSettings = useCallback(
560
+ (patch: Partial<EditorPage>) => {
561
+ setPage((prev) => {
562
+ if (!prev) return prev
563
+ note(prev, 'page', true)
564
+ return { ...prev, ...patch }
565
+ })
566
+ setDirty(true)
567
+ setSaveState('idle')
568
+ },
569
+ [note],
570
+ )
571
+
532
572
  // ─── Save / publish ──────────────────────────────────────────────────
533
573
  /**
534
574
  * Persist robots directives. They live on the SEO record, not the document
@@ -950,6 +990,21 @@ export function PageSectionEditor({
950
990
  typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
951
991
  }
952
992
  onSaveDraft={handleSaveDraft}
993
+ onUndo={effectiveCanEdit ? handleUndo : undefined}
994
+ onRedo={effectiveCanEdit ? handleRedo : undefined}
995
+ canUndo={canUndo}
996
+ canRedo={canRedo}
997
+ localeControl={
998
+ page.id ? (
999
+ <DocumentLocaleSwitcher
1000
+ collection={PAGES_COLLECTION}
1001
+ documentId={page.id}
1002
+ canCreate={effectiveCanEdit}
1003
+ editorPath={(id) => `/pages/${id}/edit`}
1004
+ onNavigate={guardedNavigate}
1005
+ />
1006
+ ) : null
1007
+ }
953
1008
  onSubmitForReview={handleSubmitForReview}
954
1009
  onApprove={handleApprove}
955
1010
  onRequestChanges={handleRequestChanges}
@@ -9,9 +9,11 @@ import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js
9
9
  import { EditorRail, RailHeader } from '../../components/EditorRail.js'
10
10
  import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
11
11
  import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
12
+ import { DocumentLocaleSwitcher } from '../../components/DocumentLocaleSwitcher.js'
12
13
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
14
  import { VersionHistoryPanel } from '../page-editor/VersionHistoryPanel.js'
14
15
  import { useDebouncedAutosave } from '../page-editor/autosave.js'
16
+ import { useEditorHistory, useSectionEditorShortcuts } from '../../hooks/use-editor-history.js'
15
17
  import { ReviewBar } from '../page-editor/ReviewBar.js'
16
18
  import {
17
19
  fetchPageVersions,
@@ -182,10 +184,13 @@ export function PostSectionEditor({
182
184
  * as a publish does; missing one would leave the guard comparing against
183
185
  * content no longer on the site.
184
186
  */
187
+ const savedJson = useRef('')
185
188
  const acceptPost = useCallback((p: EditorPost) => {
189
+ savedJson.current = JSON.stringify(p)
186
190
  setPost(p)
187
191
  setBaseline(snapshotBaseline(p.sections, p.status))
188
192
  }, [])
193
+ const { note, undo, redo, canUndo, canRedo } = useEditorHistory(post, documentId ?? null)
189
194
 
190
195
  const { canEdit } = useMemo(() => deriveCan(session), [session])
191
196
  const sessionRole = String(session?.user?.role ?? '')
@@ -393,23 +398,35 @@ export function PostSectionEditor({
393
398
 
394
399
  // ─── Section mutations ───────────────────────────────────────────────
395
400
  const mutateSections = useCallback(
396
- (fn: (sections: EditorPost['sections']) => EditorPost['sections']) => {
397
- setPost((prev) => (prev ? { ...prev, sections: fn(prev.sections) } : prev))
401
+ (
402
+ fn: (sections: EditorPost['sections']) => EditorPost['sections'],
403
+ key: string,
404
+ coalesce = false,
405
+ ) => {
406
+ setPost((prev) => {
407
+ if (!prev) return prev
408
+ note(prev, key, coalesce)
409
+ return { ...prev, sections: fn(prev.sections) }
410
+ })
398
411
  setDirty(true)
399
412
  setSaveState('idle')
400
413
  },
401
- [],
414
+ [note],
402
415
  )
403
416
 
404
417
  const handleToggleVisibility = useCallback(
405
- (id: string) => mutateSections((s) => toggleSectionVisibility(s, id)),
418
+ (id: string) => mutateSections((s) => toggleSectionVisibility(s, id), `visibility:${id}`),
406
419
  [mutateSections],
407
420
  )
408
421
 
409
422
  const handleContentChange = useCallback(
410
423
  (patch: Record<string, unknown>) => {
411
424
  if (!selectedId) return
412
- mutateSections((s) => updateSectionContent(s, selectedId, patch))
425
+ mutateSections(
426
+ (s) => updateSectionContent(s, selectedId, patch),
427
+ `content:${selectedId}`,
428
+ true,
429
+ )
413
430
  },
414
431
  [selectedId, mutateSections],
415
432
  )
@@ -419,22 +436,46 @@ export function PostSectionEditor({
419
436
  const handleMetaChange = useCallback(
420
437
  (patch: { name?: string; internalLabel?: string }) => {
421
438
  if (!selectedId) return
422
- mutateSections((s) => updateSectionMeta(s, selectedId, patch))
439
+ mutateSections((s) => updateSectionMeta(s, selectedId, patch), `meta:${selectedId}`, true)
423
440
  },
424
441
  [selectedId, mutateSections],
425
442
  )
426
443
 
427
- const handleFields = useCallback((patch: Partial<EditorPost>) => {
428
- setPost((prev) => (prev ? { ...prev, ...patch } : prev))
429
- setDirty(true)
444
+ const restoreEdit = useCallback((next: EditorPost | null) => {
445
+ if (!next) return
446
+ setPost(next)
447
+ setDirty(JSON.stringify(next) !== savedJson.current)
430
448
  setSaveState('idle')
431
449
  }, [])
432
450
 
433
- const handlePostBodyChange = useCallback((body: string) => {
434
- setPost((prev) => (prev ? { ...prev, body } : prev))
435
- setDirty(true)
436
- setSaveState('idle')
437
- }, [])
451
+ const handleUndo = useCallback(() => restoreEdit(undo()), [restoreEdit, undo])
452
+ const handleRedo = useCallback(() => restoreEdit(redo()), [restoreEdit, redo])
453
+
454
+ const handleFields = useCallback(
455
+ (patch: Partial<EditorPost>) => {
456
+ setPost((prev) => {
457
+ if (!prev) return prev
458
+ note(prev, 'fields', true)
459
+ return { ...prev, ...patch }
460
+ })
461
+ setDirty(true)
462
+ setSaveState('idle')
463
+ },
464
+ [note],
465
+ )
466
+
467
+ const handlePostBodyChange = useCallback(
468
+ (body: string) => {
469
+ setPost((prev) => {
470
+ if (!prev) return prev
471
+ note(prev, 'body', true)
472
+ return { ...prev, body }
473
+ })
474
+ setDirty(true)
475
+ setSaveState('idle')
476
+ },
477
+ [note],
478
+ )
438
479
 
439
480
  // ─── Save / publish ──────────────────────────────────────────────────
440
481
  const latestPost = useRef(post)
@@ -568,6 +609,12 @@ export function PostSectionEditor({
568
609
  const effectiveCanEdit = canEdit && !reviewing
569
610
  const canRestoreVersion = canEdit
570
611
 
612
+ useSectionEditorShortcuts({
613
+ enabled: effectiveCanEdit,
614
+ onUndo: handleUndo,
615
+ onRedo: handleRedo,
616
+ })
617
+
571
618
  useDebouncedAutosave({
572
619
  dirty,
573
620
  enabled: Boolean(documentId) && effectiveCanEdit && !publishing,
@@ -814,6 +861,21 @@ export function PostSectionEditor({
814
861
  typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
815
862
  }
816
863
  onSaveDraft={handleSaveDraft}
864
+ onUndo={effectiveCanEdit ? handleUndo : undefined}
865
+ onRedo={effectiveCanEdit ? handleRedo : undefined}
866
+ canUndo={canUndo}
867
+ canRedo={canRedo}
868
+ localeControl={
869
+ post.id ? (
870
+ <DocumentLocaleSwitcher
871
+ collection={postType}
872
+ documentId={post.id}
873
+ canCreate={effectiveCanEdit}
874
+ editorPath={(id) => `/posts/${postType}/${id}/edit`}
875
+ onNavigate={guardedNavigate}
876
+ />
877
+ ) : null
878
+ }
817
879
  onSubmitForReview={handleSubmitForReview}
818
880
  onApprove={handleApprove}
819
881
  onRequestChanges={handleRequestChanges}