@prenta/admin 2.0.2 → 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 (58) 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__/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/lib/seo-service.d.ts +2 -0
  30. package/dist/lib/seo-service.d.ts.map +1 -1
  31. package/dist/lib/seo-service.js.map +1 -1
  32. package/dist/views/page-editor/EditorTopBar.d.ts +6 -1
  33. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  34. package/dist/views/page-editor/EditorTopBar.js +3 -3
  35. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  36. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  37. package/dist/views/page-editor/PageSectionEditor.js +37 -9
  38. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  39. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  40. package/dist/views/post-editor/PostSectionEditor.js +44 -11
  41. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  42. package/dist/views/seo/AuditTab.d.ts.map +1 -1
  43. package/dist/views/seo/AuditTab.js +7 -2
  44. package/dist/views/seo/AuditTab.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/AdminRoot.tsx +5 -9
  47. package/src/__tests__/lib/admin-shortcuts.test.ts +11 -0
  48. package/src/__tests__/lib/editor-history.test.ts +99 -0
  49. package/src/__tests__/views/editor-top-bar.render.test.tsx +27 -0
  50. package/src/__tests__/views/post-section-editor.test.tsx +30 -0
  51. package/src/hooks/use-editor-history.ts +102 -0
  52. package/src/lib/admin-shortcuts.ts +10 -0
  53. package/src/lib/editor-history.ts +83 -0
  54. package/src/lib/seo-service.ts +2 -0
  55. package/src/views/page-editor/EditorTopBar.tsx +35 -0
  56. package/src/views/page-editor/PageSectionEditor.tsx +53 -10
  57. package/src/views/post-editor/PostSectionEditor.tsx +64 -14
  58. package/src/views/seo/AuditTab.tsx +18 -1
@@ -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}
@@ -69,8 +69,22 @@ function AuditSummaryCards({ refetchKey }: { refetchKey: number }) {
69
69
  const { data, loading } = useSeoResource(fetchSeoAuditRuns, [refetchKey])
70
70
  const last = (data ?? [])[0]
71
71
  const coverage = last?.metadata?.renderedAudit
72
+ const total = last?.metadata?.total
73
+ const running = last?.status === 'RUNNING'
74
+ const truncated = last?.metadata?.truncated === true
75
+
72
76
  return (
73
77
  <div className="space-y-4">
78
+ {truncated ? (
79
+ <p
80
+ className="border-border bg-card text-foreground rounded-lg border px-4 py-3 text-sm"
81
+ role="status"
82
+ >
83
+ This audit covered {last?.pagesScanned ?? 0}
84
+ {typeof total === 'number' ? ` of ${total}` : ''} published pages and stopped before the
85
+ rest. Issues on the pages it did not reach were kept.
86
+ </p>
87
+ ) : null}
74
88
  <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
75
89
  <div className="border-border bg-card rounded-lg border p-4">
76
90
  <p className="text-muted-foreground flex items-center gap-1.5 text-sm">
@@ -83,8 +97,11 @@ function AuditSummaryCards({ refetchKey }: { refetchKey: number }) {
83
97
  <div className="border-border bg-card rounded-lg border p-4">
84
98
  <p className="text-muted-foreground text-sm">Pages scanned</p>
85
99
  <p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
86
- {last?.pagesScanned ?? 0}
100
+ {running && typeof total === 'number'
101
+ ? `${last?.pagesScanned ?? 0} of ${total}`
102
+ : (last?.pagesScanned ?? 0)}
87
103
  </p>
104
+ {running ? <p className="text-muted-foreground mt-1 text-sm">Audit in progress</p> : null}
88
105
  {coverage ? (
89
106
  <p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-sm">
90
107
  <span>{coverage.verified} verified</span>