@prenta/admin 2.0.3 → 2.1.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 (50) hide show
  1. package/CHANGELOG.md +6 -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__/lib/admin-shortcuts.test.d.ts +2 -0
  6. package/dist/__tests__/lib/admin-shortcuts.test.d.ts.map +1 -0
  7. package/dist/__tests__/lib/admin-shortcuts.test.js +11 -0
  8. package/dist/__tests__/lib/admin-shortcuts.test.js.map +1 -0
  9. package/dist/__tests__/lib/editor-history.test.d.ts +2 -0
  10. package/dist/__tests__/lib/editor-history.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/editor-history.test.js +76 -0
  12. package/dist/__tests__/lib/editor-history.test.js.map +1 -0
  13. package/dist/__tests__/views/editor-top-bar.render.test.js +17 -0
  14. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  15. package/dist/__tests__/views/post-section-editor.test.js +16 -0
  16. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  17. package/dist/hooks/use-editor-history.d.ts +19 -0
  18. package/dist/hooks/use-editor-history.d.ts.map +1 -0
  19. package/dist/hooks/use-editor-history.js +94 -0
  20. package/dist/hooks/use-editor-history.js.map +1 -0
  21. package/dist/lib/admin-shortcuts.d.ts +6 -0
  22. package/dist/lib/admin-shortcuts.d.ts.map +1 -0
  23. package/dist/lib/admin-shortcuts.js +11 -0
  24. package/dist/lib/admin-shortcuts.js.map +1 -0
  25. package/dist/lib/editor-history.d.ts +24 -0
  26. package/dist/lib/editor-history.d.ts.map +1 -0
  27. package/dist/lib/editor-history.js +61 -0
  28. package/dist/lib/editor-history.js.map +1 -0
  29. package/dist/views/page-editor/EditorTopBar.d.ts +6 -1
  30. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  31. package/dist/views/page-editor/EditorTopBar.js +3 -3
  32. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  33. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  34. package/dist/views/page-editor/PageSectionEditor.js +37 -9
  35. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  36. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  37. package/dist/views/post-editor/PostSectionEditor.js +44 -11
  38. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/AdminRoot.tsx +5 -9
  41. package/src/__tests__/lib/admin-shortcuts.test.ts +11 -0
  42. package/src/__tests__/lib/editor-history.test.ts +99 -0
  43. package/src/__tests__/views/editor-top-bar.render.test.tsx +27 -0
  44. package/src/__tests__/views/post-section-editor.test.tsx +30 -0
  45. package/src/hooks/use-editor-history.ts +102 -0
  46. package/src/lib/admin-shortcuts.ts +10 -0
  47. package/src/lib/editor-history.ts +83 -0
  48. package/src/views/page-editor/EditorTopBar.tsx +35 -0
  49. package/src/views/page-editor/PageSectionEditor.tsx +53 -10
  50. package/src/views/post-editor/PostSectionEditor.tsx +64 -14
@@ -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,11 @@ 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
102
109
  onSubmitForReview: () => void
103
110
  onApprove: () => void
104
111
  onRequestChanges: (note: string) => void
@@ -149,6 +156,10 @@ export function EditorTopBar({
149
156
  seoScore,
150
157
  seoScoreBand,
151
158
  onSaveDraft,
159
+ onUndo,
160
+ onRedo,
161
+ canUndo = false,
162
+ canRedo = false,
152
163
  onSubmitForReview,
153
164
  onApprove,
154
165
  onRequestChanges,
@@ -290,6 +301,30 @@ export function EditorTopBar({
290
301
  </span>
291
302
  )}
292
303
  <SaveStatus state={saveState} dirty={dirty} />
304
+ {onUndo && onRedo && (
305
+ <span className="ml-1 inline-flex shrink-0 items-center">
306
+ <button
307
+ type="button"
308
+ onClick={onUndo}
309
+ disabled={!canUndo}
310
+ aria-label="Undo"
311
+ title="Undo the last edit (Ctrl+Z or ⌘Z). Text fields keep their own undo."
312
+ className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
313
+ >
314
+ <Undo2 className="h-4 w-4" aria-hidden />
315
+ </button>
316
+ <button
317
+ type="button"
318
+ onClick={onRedo}
319
+ disabled={!canRedo}
320
+ aria-label="Redo"
321
+ title="Redo (Ctrl+Shift+Z, Ctrl+Y, or ⌘⇧Z)."
322
+ className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2 disabled:cursor-not-allowed disabled:opacity-40"
323
+ >
324
+ <Redo2 className="h-4 w-4" aria-hidden />
325
+ </button>
326
+ </span>
327
+ )}
293
328
  </div>
294
329
 
295
330
  {/* Right controls */}
@@ -12,6 +12,7 @@ import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/E
12
12
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
13
  import { VersionHistoryPanel } from './VersionHistoryPanel.js'
14
14
  import { useDebouncedAutosave } from './autosave.js'
15
+ import { useEditorHistory, useSectionEditorShortcuts } from '../../hooks/use-editor-history.js'
15
16
  import { ReviewBar } from './ReviewBar.js'
16
17
  import {
17
18
  fetchPageVersions,
@@ -215,11 +216,14 @@ export function PageSectionEditor({
215
216
  * so a save moves the baseline exactly as a publish does. Missing one would
216
217
  * leave the guard comparing against content that is no longer on the site.
217
218
  */
219
+ const savedJson = useRef('')
218
220
  const acceptPage = useCallback((p: EditorPage) => {
221
+ savedJson.current = JSON.stringify(p)
219
222
  setPage(p)
220
223
  setBaseline(snapshotBaseline(p.sections, p.status))
221
224
  setPublishedPath(p.status === 'PUBLISHED' ? p.path : null)
222
225
  }, [])
226
+ const { note, undo, redo, canUndo, canRedo } = useEditorHistory(page, documentId ?? null)
223
227
  const [dirty, setDirty] = useState(false)
224
228
  const [saveState, setSaveState] = useState<SaveState>('idle')
225
229
  const [publishing, setPublishing] = useState(false)
@@ -486,23 +490,35 @@ export function PageSectionEditor({
486
490
 
487
491
  // ─── Section mutations ───────────────────────────────────────────────
488
492
  const mutateSections = useCallback(
489
- (fn: (sections: EditorPage['sections']) => EditorPage['sections']) => {
490
- setPage((prev) => (prev ? { ...prev, sections: fn(prev.sections) } : prev))
493
+ (
494
+ fn: (sections: EditorPage['sections']) => EditorPage['sections'],
495
+ key: string,
496
+ coalesce = false,
497
+ ) => {
498
+ setPage((prev) => {
499
+ if (!prev) return prev
500
+ note(prev, key, coalesce)
501
+ return { ...prev, sections: fn(prev.sections) }
502
+ })
491
503
  setDirty(true)
492
504
  setSaveState('idle')
493
505
  },
494
- [],
506
+ [note],
495
507
  )
496
508
 
497
509
  const handleToggleVisibility = useCallback(
498
- (id: string) => mutateSections((s) => toggleSectionVisibility(s, id)),
510
+ (id: string) => mutateSections((s) => toggleSectionVisibility(s, id), `visibility:${id}`),
499
511
  [mutateSections],
500
512
  )
501
513
 
502
514
  const handleContentChange = useCallback(
503
515
  (patch: Record<string, unknown>) => {
504
516
  if (!selectedId) return
505
- mutateSections((s) => updateSectionContent(s, selectedId, patch))
517
+ mutateSections(
518
+ (s) => updateSectionContent(s, selectedId, patch),
519
+ `content:${selectedId}`,
520
+ true,
521
+ )
506
522
  },
507
523
  [selectedId, mutateSections],
508
524
  )
@@ -517,18 +533,41 @@ export function PageSectionEditor({
517
533
  const handleMetaChange = useCallback(
518
534
  (patch: { name?: string; internalLabel?: string }) => {
519
535
  if (!selectedId) return
520
- mutateSections((s) => updateSectionMeta(s, selectedId, patch))
536
+ mutateSections((s) => updateSectionMeta(s, selectedId, patch), `meta:${selectedId}`, true)
521
537
  },
522
538
  [selectedId, mutateSections],
523
539
  )
524
540
 
525
- // ─── Page-level settings ─────────────────────────────────────────────
526
- const handlePageSettings = useCallback((patch: Partial<EditorPage>) => {
527
- setPage((prev) => (prev ? { ...prev, ...patch } : prev))
528
- setDirty(true)
541
+ const restoreEdit = useCallback((next: EditorPage | null) => {
542
+ if (!next) return
543
+ setPage(next)
544
+ setDirty(JSON.stringify(next) !== savedJson.current)
529
545
  setSaveState('idle')
530
546
  }, [])
531
547
 
548
+ const handleUndo = useCallback(() => restoreEdit(undo()), [restoreEdit, undo])
549
+ const handleRedo = useCallback(() => restoreEdit(redo()), [restoreEdit, redo])
550
+
551
+ useSectionEditorShortcuts({
552
+ enabled: effectiveCanEdit,
553
+ onUndo: handleUndo,
554
+ onRedo: handleRedo,
555
+ })
556
+
557
+ // ─── Page-level settings ─────────────────────────────────────────────
558
+ const handlePageSettings = useCallback(
559
+ (patch: Partial<EditorPage>) => {
560
+ setPage((prev) => {
561
+ if (!prev) return prev
562
+ note(prev, 'page', true)
563
+ return { ...prev, ...patch }
564
+ })
565
+ setDirty(true)
566
+ setSaveState('idle')
567
+ },
568
+ [note],
569
+ )
570
+
532
571
  // ─── Save / publish ──────────────────────────────────────────────────
533
572
  /**
534
573
  * Persist robots directives. They live on the SEO record, not the document
@@ -950,6 +989,10 @@ export function PageSectionEditor({
950
989
  typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
951
990
  }
952
991
  onSaveDraft={handleSaveDraft}
992
+ onUndo={effectiveCanEdit ? handleUndo : undefined}
993
+ onRedo={effectiveCanEdit ? handleRedo : undefined}
994
+ canUndo={canUndo}
995
+ canRedo={canRedo}
953
996
  onSubmitForReview={handleSubmitForReview}
954
997
  onApprove={handleApprove}
955
998
  onRequestChanges={handleRequestChanges}
@@ -12,6 +12,7 @@ import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
12
12
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
13
  import { VersionHistoryPanel } from '../page-editor/VersionHistoryPanel.js'
14
14
  import { useDebouncedAutosave } from '../page-editor/autosave.js'
15
+ import { useEditorHistory, useSectionEditorShortcuts } from '../../hooks/use-editor-history.js'
15
16
  import { ReviewBar } from '../page-editor/ReviewBar.js'
16
17
  import {
17
18
  fetchPageVersions,
@@ -182,10 +183,13 @@ export function PostSectionEditor({
182
183
  * as a publish does; missing one would leave the guard comparing against
183
184
  * content no longer on the site.
184
185
  */
186
+ const savedJson = useRef('')
185
187
  const acceptPost = useCallback((p: EditorPost) => {
188
+ savedJson.current = JSON.stringify(p)
186
189
  setPost(p)
187
190
  setBaseline(snapshotBaseline(p.sections, p.status))
188
191
  }, [])
192
+ const { note, undo, redo, canUndo, canRedo } = useEditorHistory(post, documentId ?? null)
189
193
 
190
194
  const { canEdit } = useMemo(() => deriveCan(session), [session])
191
195
  const sessionRole = String(session?.user?.role ?? '')
@@ -393,23 +397,35 @@ export function PostSectionEditor({
393
397
 
394
398
  // ─── Section mutations ───────────────────────────────────────────────
395
399
  const mutateSections = useCallback(
396
- (fn: (sections: EditorPost['sections']) => EditorPost['sections']) => {
397
- setPost((prev) => (prev ? { ...prev, sections: fn(prev.sections) } : prev))
400
+ (
401
+ fn: (sections: EditorPost['sections']) => EditorPost['sections'],
402
+ key: string,
403
+ coalesce = false,
404
+ ) => {
405
+ setPost((prev) => {
406
+ if (!prev) return prev
407
+ note(prev, key, coalesce)
408
+ return { ...prev, sections: fn(prev.sections) }
409
+ })
398
410
  setDirty(true)
399
411
  setSaveState('idle')
400
412
  },
401
- [],
413
+ [note],
402
414
  )
403
415
 
404
416
  const handleToggleVisibility = useCallback(
405
- (id: string) => mutateSections((s) => toggleSectionVisibility(s, id)),
417
+ (id: string) => mutateSections((s) => toggleSectionVisibility(s, id), `visibility:${id}`),
406
418
  [mutateSections],
407
419
  )
408
420
 
409
421
  const handleContentChange = useCallback(
410
422
  (patch: Record<string, unknown>) => {
411
423
  if (!selectedId) return
412
- mutateSections((s) => updateSectionContent(s, selectedId, patch))
424
+ mutateSections(
425
+ (s) => updateSectionContent(s, selectedId, patch),
426
+ `content:${selectedId}`,
427
+ true,
428
+ )
413
429
  },
414
430
  [selectedId, mutateSections],
415
431
  )
@@ -419,22 +435,46 @@ export function PostSectionEditor({
419
435
  const handleMetaChange = useCallback(
420
436
  (patch: { name?: string; internalLabel?: string }) => {
421
437
  if (!selectedId) return
422
- mutateSections((s) => updateSectionMeta(s, selectedId, patch))
438
+ mutateSections((s) => updateSectionMeta(s, selectedId, patch), `meta:${selectedId}`, true)
423
439
  },
424
440
  [selectedId, mutateSections],
425
441
  )
426
442
 
427
- const handleFields = useCallback((patch: Partial<EditorPost>) => {
428
- setPost((prev) => (prev ? { ...prev, ...patch } : prev))
429
- setDirty(true)
443
+ const restoreEdit = useCallback((next: EditorPost | null) => {
444
+ if (!next) return
445
+ setPost(next)
446
+ setDirty(JSON.stringify(next) !== savedJson.current)
430
447
  setSaveState('idle')
431
448
  }, [])
432
449
 
433
- const handlePostBodyChange = useCallback((body: string) => {
434
- setPost((prev) => (prev ? { ...prev, body } : prev))
435
- setDirty(true)
436
- setSaveState('idle')
437
- }, [])
450
+ const handleUndo = useCallback(() => restoreEdit(undo()), [restoreEdit, undo])
451
+ const handleRedo = useCallback(() => restoreEdit(redo()), [restoreEdit, redo])
452
+
453
+ const handleFields = useCallback(
454
+ (patch: Partial<EditorPost>) => {
455
+ setPost((prev) => {
456
+ if (!prev) return prev
457
+ note(prev, 'fields', true)
458
+ return { ...prev, ...patch }
459
+ })
460
+ setDirty(true)
461
+ setSaveState('idle')
462
+ },
463
+ [note],
464
+ )
465
+
466
+ const handlePostBodyChange = useCallback(
467
+ (body: string) => {
468
+ setPost((prev) => {
469
+ if (!prev) return prev
470
+ note(prev, 'body', true)
471
+ return { ...prev, body }
472
+ })
473
+ setDirty(true)
474
+ setSaveState('idle')
475
+ },
476
+ [note],
477
+ )
438
478
 
439
479
  // ─── Save / publish ──────────────────────────────────────────────────
440
480
  const latestPost = useRef(post)
@@ -568,6 +608,12 @@ export function PostSectionEditor({
568
608
  const effectiveCanEdit = canEdit && !reviewing
569
609
  const canRestoreVersion = canEdit
570
610
 
611
+ useSectionEditorShortcuts({
612
+ enabled: effectiveCanEdit,
613
+ onUndo: handleUndo,
614
+ onRedo: handleRedo,
615
+ })
616
+
571
617
  useDebouncedAutosave({
572
618
  dirty,
573
619
  enabled: Boolean(documentId) && effectiveCanEdit && !publishing,
@@ -814,6 +860,10 @@ export function PostSectionEditor({
814
860
  typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
815
861
  }
816
862
  onSaveDraft={handleSaveDraft}
863
+ onUndo={effectiveCanEdit ? handleUndo : undefined}
864
+ onRedo={effectiveCanEdit ? handleRedo : undefined}
865
+ canUndo={canUndo}
866
+ canRedo={canRedo}
817
867
  onSubmitForReview={handleSubmitForReview}
818
868
  onApprove={handleApprove}
819
869
  onRequestChanges={handleRequestChanges}