@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.
- package/CHANGELOG.md +12 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -6
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/admin-shortcuts.test.d.ts +2 -0
- package/dist/__tests__/lib/admin-shortcuts.test.d.ts.map +1 -0
- package/dist/__tests__/lib/admin-shortcuts.test.js +11 -0
- package/dist/__tests__/lib/admin-shortcuts.test.js.map +1 -0
- package/dist/__tests__/lib/editor-history.test.d.ts +2 -0
- package/dist/__tests__/lib/editor-history.test.d.ts.map +1 -0
- package/dist/__tests__/lib/editor-history.test.js +76 -0
- package/dist/__tests__/lib/editor-history.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +17 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +16 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/hooks/use-editor-history.d.ts +19 -0
- package/dist/hooks/use-editor-history.d.ts.map +1 -0
- package/dist/hooks/use-editor-history.js +94 -0
- package/dist/hooks/use-editor-history.js.map +1 -0
- package/dist/lib/admin-shortcuts.d.ts +6 -0
- package/dist/lib/admin-shortcuts.d.ts.map +1 -0
- package/dist/lib/admin-shortcuts.js +11 -0
- package/dist/lib/admin-shortcuts.js.map +1 -0
- package/dist/lib/editor-history.d.ts +24 -0
- package/dist/lib/editor-history.d.ts.map +1 -0
- package/dist/lib/editor-history.js +61 -0
- package/dist/lib/editor-history.js.map +1 -0
- package/dist/lib/seo-service.d.ts +2 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +6 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +3 -3
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +37 -9
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +44 -11
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts.map +1 -1
- package/dist/views/seo/AuditTab.js +7 -2
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/package.json +3 -3
- package/src/AdminRoot.tsx +5 -9
- package/src/__tests__/lib/admin-shortcuts.test.ts +11 -0
- package/src/__tests__/lib/editor-history.test.ts +99 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +27 -0
- package/src/__tests__/views/post-section-editor.test.tsx +30 -0
- package/src/hooks/use-editor-history.ts +102 -0
- package/src/lib/admin-shortcuts.ts +10 -0
- package/src/lib/editor-history.ts +83 -0
- package/src/lib/seo-service.ts +2 -0
- package/src/views/page-editor/EditorTopBar.tsx +35 -0
- package/src/views/page-editor/PageSectionEditor.tsx +53 -10
- package/src/views/post-editor/PostSectionEditor.tsx +64 -14
- 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
|
-
(
|
|
490
|
-
|
|
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(
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
setPage(
|
|
528
|
-
setDirty(
|
|
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
|
-
(
|
|
397
|
-
|
|
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(
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
{
|
|
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>
|