@prenta/admin 1.2.0 → 1.3.1

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 (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/design-system/README.md +1 -1
  3. package/design-system/tokens.css +3 -3
  4. package/design-system/tokens.json +2 -2
  5. package/design-system/tokens.scss +1 -3
  6. package/dist/__tests__/lib/page-audit-input.test.js +81 -0
  7. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
  8. package/dist/__tests__/lib/version-history.test.js +18 -0
  9. package/dist/__tests__/lib/version-history.test.js.map +1 -1
  10. package/dist/__tests__/no-server-barrel-imports.test.js +3 -4
  11. package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
  12. package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
  13. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  14. package/dist/__tests__/views/post-section-editor.test.js +38 -0
  15. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  16. package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/post-tab.render.test.js +86 -0
  19. package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
  20. package/dist/components/CanvasEditTip.js +1 -1
  21. package/dist/components/CanvasEditTip.js.map +1 -1
  22. package/dist/components/seo/SeoEditorPane.d.ts +9 -1
  23. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  24. package/dist/components/seo/SeoEditorPane.js +5 -2
  25. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  26. package/dist/hooks/usePageAudit.d.ts +4 -1
  27. package/dist/hooks/usePageAudit.d.ts.map +1 -1
  28. package/dist/hooks/usePageAudit.js +3 -3
  29. package/dist/hooks/usePageAudit.js.map +1 -1
  30. package/dist/lib/page-audit-input.d.ts +26 -1
  31. package/dist/lib/page-audit-input.d.ts.map +1 -1
  32. package/dist/lib/page-audit-input.js +48 -2
  33. package/dist/lib/page-audit-input.js.map +1 -1
  34. package/dist/lib/version-history.d.ts +5 -0
  35. package/dist/lib/version-history.d.ts.map +1 -1
  36. package/dist/lib/version-history.js +18 -0
  37. package/dist/lib/version-history.js.map +1 -1
  38. package/dist/prenta-admin.css +1 -1
  39. package/dist/views/page-editor/EditorTopBar.js +1 -1
  40. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  41. package/dist/views/post-editor/PostEditorCanvas.d.ts +14 -1
  42. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  43. package/dist/views/post-editor/PostEditorCanvas.js +2 -2
  44. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  45. package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
  46. package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
  47. package/dist/views/post-editor/PostFieldsForm.js +85 -0
  48. package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
  49. package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
  50. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  51. package/dist/views/post-editor/PostFieldsModal.js +20 -81
  52. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  53. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  54. package/dist/views/post-editor/PostSectionEditor.js +122 -34
  55. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  56. package/dist/views/post-editor/PostTab.d.ts +17 -0
  57. package/dist/views/post-editor/PostTab.d.ts.map +1 -0
  58. package/dist/views/post-editor/PostTab.js +23 -0
  59. package/dist/views/post-editor/PostTab.js.map +1 -0
  60. package/dist/views/settings/useAppearanceSettings.js +1 -1
  61. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  62. package/package.json +6 -6
  63. package/src/__tests__/lib/page-audit-input.test.ts +99 -0
  64. package/src/__tests__/lib/version-history.test.ts +20 -0
  65. package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
  66. package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
  67. package/src/__tests__/views/post-section-editor.test.tsx +59 -0
  68. package/src/__tests__/views/post-tab.render.test.tsx +116 -0
  69. package/src/components/CanvasEditTip.tsx +2 -2
  70. package/src/components/seo/SeoEditorPane.tsx +16 -1
  71. package/src/hooks/usePageAudit.ts +9 -2
  72. package/src/lib/page-audit-input.ts +67 -1
  73. package/src/lib/version-history.ts +23 -0
  74. package/src/styles/prenta-tokens.css +3 -3
  75. package/src/views/page-editor/EditorTopBar.tsx +2 -2
  76. package/src/views/post-editor/PostEditorCanvas.tsx +27 -5
  77. package/src/views/post-editor/PostFieldsForm.tsx +322 -0
  78. package/src/views/post-editor/PostFieldsModal.tsx +30 -281
  79. package/src/views/post-editor/PostSectionEditor.tsx +204 -59
  80. package/src/views/post-editor/PostTab.tsx +46 -0
  81. package/src/views/settings/useAppearanceSettings.ts +1 -1
@@ -10,7 +10,16 @@ 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
12
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
13
- import { VersionHistory } from '../../components/VersionHistory.js'
13
+ import { VersionHistoryPanel } from '../page-editor/VersionHistoryPanel.js'
14
+ import { ReviewBar } from '../page-editor/ReviewBar.js'
15
+ import {
16
+ fetchPageVersions,
17
+ restorePageVersion,
18
+ type PageVersion,
19
+ } from '../../lib/version-history.js'
20
+ import { changedSectionIds, diffVersions } from '../../lib/version-diff.js'
21
+ import { scoreBand, type PageAuditResult } from '@prenta/core/seo/page-audit'
22
+ import { usePageAudit } from '../../hooks/usePageAudit.js'
14
23
  import {
15
24
  emptyPostFromTemplate,
16
25
  fetchPostForEditor,
@@ -41,13 +50,25 @@ import { EditorTopBar, type SaveState } from '../page-editor/EditorTopBar.js'
41
50
  import { SectionsList } from '../page-editor/SectionsPanel.js'
42
51
  import { SectionEditForm } from '../page-editor/SectionEditForm.js'
43
52
  import { ValidationSummary } from '../page-editor/ValidationSummary.js'
44
- import { buildEditorContentHtml, computeLiveSeoScore } from '../../lib/editor-seo-content.js'
53
+ import { buildEditorContentHtml } from '../../lib/editor-seo-content.js'
45
54
  import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
46
55
  import { ReflowContext } from '../page-editor/reflow-context.js'
47
56
  import type { Zoom } from '../../lib/breakpoints.js'
48
57
  import { inspectorIdFromFieldPath } from '../../lib/preview-bridge.js'
49
58
  import { PostEditorCanvas } from './PostEditorCanvas.js'
50
59
  import { PostFieldsModal, type PostFieldConfig } from './PostFieldsModal.js'
60
+ import { PostTab } from './PostTab.js'
61
+
62
+ /**
63
+ * The lower of the two audit scores — the tab dot should reflect the half that
64
+ * needs work, not an average that hides it. Same three-valued contract as the
65
+ * page editor: undefined = not reported yet, null = gated off, number = score.
66
+ */
67
+ function weakestAuditScore(audit: PageAuditResult | null): number | null | undefined {
68
+ if (!audit) return undefined
69
+ const scores = [audit.seo.score, audit.geo.score].filter((s): s is number => s !== null)
70
+ return scores.length ? Math.min(...scores) : null
71
+ }
51
72
 
52
73
  /** The slice of the resolved CMS config this editor reads. */
53
74
  interface EditorConfig {
@@ -127,6 +148,18 @@ export function PostSectionEditor({
127
148
  const [railTab, setRailTab] = useState<RailTabId>('sections')
128
149
  const seoOpen = railTab === 'seo'
129
150
  const [historyOpen, setHistoryOpen] = useState(false)
151
+ const [versions, setVersions] = useState<PageVersion[]>([])
152
+ const [versionsLoading, setVersionsLoading] = useState(false)
153
+ const [versionsError, setVersionsError] = useState<string | null>(null)
154
+ /** Index into `versions`; 0 is the newest, which is the current content. */
155
+ const [versionIndex, setVersionIndex] = useState(0)
156
+ const [restoring, setRestoring] = useState(false)
157
+ /**
158
+ * Live audit reported by the SEO pane, built from unsaved form state. Null
159
+ * until that pane has mounted — `audit` below falls back to the saved-record
160
+ * audit so the header chip and tab dot always have a number.
161
+ */
162
+ const [liveAudit, setLiveAudit] = useState<PageAuditResult | null>(null)
130
163
  const [confirmPublish, setConfirmPublish] = useState(false)
131
164
  const [scheduleOpen, setScheduleOpen] = useState(false)
132
165
  const [shareOpen, setShareOpen] = useState(false)
@@ -179,6 +212,20 @@ export function PostSectionEditor({
179
212
  )
180
213
  const publishReady = Boolean(post) && publishCheck.errors.length === 0
181
214
 
215
+ const { audit: savedAudit } = usePageAudit({
216
+ entityType: 'post',
217
+ entityId: post?.id ?? null,
218
+ sections: post?.sections ?? [],
219
+ slug: post?.slug ?? '',
220
+ fallback: { title: post?.seoTitle, description: post?.seoDescription },
221
+ post: {
222
+ title: post?.title,
223
+ bodyHtml: post?.body,
224
+ featuredImage: post?.featuredImage,
225
+ },
226
+ })
227
+ const audit = liveAudit ?? savedAudit
228
+
182
229
  const railTabs = useMemo<RailTab[]>(
183
230
  () => [
184
231
  {
@@ -187,13 +234,19 @@ export function PostSectionEditor({
187
234
  title: 'Post content',
188
235
  count: post?.sections.length ?? 0,
189
236
  },
190
- // No score dot yet. The page editor's comes from the page audit, which
191
- // is page-scoped; wiring a number here from `computeLiveSeoScore` would
192
- // put a different model's score on a tab, which is the disagreement the
193
- // page editor was rewired to remove.
194
- { id: 'seo', label: 'SEO & GEO', title: 'Search & AI readiness' },
237
+ {
238
+ id: 'seo',
239
+ label: 'SEO & GEO',
240
+ title: 'Search & AI readiness',
241
+ score: weakestAuditScore(audit),
242
+ },
243
+ {
244
+ id: 'page',
245
+ label: 'Post',
246
+ title: 'Post settings',
247
+ },
195
248
  ],
196
- [post?.sections.length],
249
+ [post?.sections.length, audit],
197
250
  )
198
251
  const activeRailTab = railTabs.find((tab) => tab.id === railTab) ?? railTabs[0]!
199
252
 
@@ -218,17 +271,6 @@ export function PostSectionEditor({
218
271
  [canvasEl, selectedId, baseline, viewport],
219
272
  )
220
273
 
221
- const liveSeoScore = useMemo(() => {
222
- if (!post) return 0
223
- return computeLiveSeoScore({
224
- title: post.title,
225
- slug: post.slug,
226
- seoTitle: post.seoTitle,
227
- seoDescription: post.seoDescription,
228
- sections: post.sections,
229
- bodyHtml: post.body,
230
- })
231
- }, [post])
232
274
  const publishDisabledReason = publishCheck.errors[0]
233
275
 
234
276
  // ─── Load ──────────────────────────────────────────────────────────
@@ -437,23 +479,77 @@ export function PostSectionEditor({
437
479
  }
438
480
  }, [post, documentId, postType, onNavigate])
439
481
 
440
- // After a version restore the server has already written the restored
441
- // data refetch so the editor reflects it instead of the stale state.
442
- const handleRestored = useCallback(() => {
482
+ // ─── Version history ─────────────────────────────────────────────────
483
+ // History is a MODE, not a dialog: it overlays the rail and drives the
484
+ // canvas, so the version being reviewed is editor state rather than
485
+ // something owned by a panel that can be unmounted.
486
+
487
+ const loadVersions = useCallback(
488
+ async (postId: string) => {
489
+ setVersionsLoading(true)
490
+ setVersionsError(null)
491
+ try {
492
+ setVersions(await fetchPageVersions(postType, postId))
493
+ } catch (err) {
494
+ setVersions([])
495
+ setVersionsError(err instanceof Error ? err.message : 'Failed to load version history')
496
+ } finally {
497
+ setVersionsLoading(false)
498
+ }
499
+ },
500
+ [postType],
501
+ )
502
+
503
+ const openHistory = useCallback(() => {
443
504
  if (!post?.id) return
444
- fetchPostForEditor(postType, post.id)
445
- .then((p) => {
446
- acceptPost(p)
505
+ setHistoryOpen(true)
506
+ setVersionIndex(0)
507
+ void loadVersions(post.id)
508
+ }, [post?.id, loadVersions])
509
+
510
+ const closeHistory = useCallback(() => {
511
+ setHistoryOpen(false)
512
+ setVersionIndex(0)
513
+ }, [])
514
+
515
+ const reviewing = historyOpen && versionIndex > 0
516
+ const reviewVersion = reviewing ? (versions[versionIndex] ?? null) : null
517
+
518
+ const reviewChanges = useMemo(
519
+ () =>
520
+ reviewVersion
521
+ ? diffVersions(reviewVersion.sections, versions[versionIndex + 1]?.sections)
522
+ : [],
523
+ [reviewVersion, versions, versionIndex],
524
+ )
525
+ const reviewChangedIds = useMemo(() => changedSectionIds(reviewChanges), [reviewChanges])
526
+
527
+ const handleRestoreVersion = useCallback(
528
+ async (version: PageVersion) => {
529
+ if (!post?.id) return
530
+ setRestoring(true)
531
+ try {
532
+ await restorePageVersion(postType, post.id, version.id)
533
+ const restored = await fetchPostForEditor(postType, post.id)
534
+ acceptPost(restored)
447
535
  setSelectedId(null)
448
- setRailTab('sections')
449
536
  setDirty(false)
450
537
  setSaveState('idle')
451
538
  setValidation(null)
452
- })
453
- .catch(() => {
454
- toast.error('Version restored — refresh the page to see it.')
455
- })
456
- }, [postType, post?.id])
539
+ closeHistory()
540
+ void loadVersions(post.id)
541
+ toast.success('Version restored')
542
+ } catch (err) {
543
+ toast.error(err instanceof Error ? err.message : 'Failed to restore version')
544
+ } finally {
545
+ setRestoring(false)
546
+ }
547
+ },
548
+ [post?.id, postType, acceptPost, closeHistory, loadVersions],
549
+ )
550
+
551
+ const effectiveCanEdit = canEdit && !reviewing
552
+ const canRestoreVersion = canEdit
457
553
 
458
554
  const handleSeoSaved = useCallback(() => {
459
555
  if (!post?.id) return
@@ -590,8 +686,8 @@ export function PostSectionEditor({
590
686
  dirty={dirty}
591
687
  saveState={saveState}
592
688
  publishing={publishing}
593
- canEdit={canEdit}
594
- canPublish={canPublish && canEdit}
689
+ canEdit={effectiveCanEdit}
690
+ canPublish={canPublish && effectiveCanEdit}
595
691
  publishReady={publishReady}
596
692
  publishDisabledReason={publishDisabledReason}
597
693
  viewport={viewport}
@@ -605,11 +701,15 @@ export function PostSectionEditor({
605
701
  onShare={post.id ? handleShare : undefined}
606
702
  hasPublicSiteUrl={Boolean(publicSiteUrl)}
607
703
  matchesAdminOrigin={matchesAdminOrigin}
608
- onHistory={post.id ? () => setHistoryOpen(true) : undefined}
704
+ historyOpen={historyOpen}
705
+ onHistory={post.id ? () => (historyOpen ? closeHistory() : openHistory()) : undefined}
609
706
  onOpenSettings={() => setFieldsOpen(true)}
610
707
  onOpenSeo={handleToggleSeo}
611
708
  seoOpen={seoOpen}
612
- seoScore={liveSeoScore}
709
+ seoScore={audit?.seo.score ?? undefined}
710
+ seoScoreBand={
711
+ typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
712
+ }
613
713
  onSaveDraft={handleSaveDraft}
614
714
  onPublish={requestPublish}
615
715
  onSchedule={post.id ? requestSchedule : undefined}
@@ -667,27 +767,44 @@ export function PostSectionEditor({
667
767
  <SectionsList
668
768
  sections={post.sections}
669
769
  selectedId={selectedId}
670
- canEdit={canEdit}
770
+ canEdit={effectiveCanEdit}
671
771
  onSelect={handleSelectSection}
672
772
  onToggleVisibility={handleToggleVisibility}
673
- renderEditor={(section) => (
674
- <SectionEditForm
675
- section={section}
676
- canEdit={canEdit}
677
- onContentChange={handleContentChange}
678
- onMetaChange={handleMetaChange}
679
- postBody={post.body}
680
- onPostBodyChange={handlePostBodyChange}
681
- />
682
- )}
773
+ renderEditor={
774
+ reviewing
775
+ ? undefined
776
+ : (section) => (
777
+ <SectionEditForm
778
+ section={section}
779
+ canEdit={effectiveCanEdit}
780
+ onContentChange={handleContentChange}
781
+ onMetaChange={handleMetaChange}
782
+ postBody={post.body}
783
+ onPostBodyChange={handlePostBodyChange}
784
+ />
785
+ )
786
+ }
683
787
  />
788
+ ) : railTab === 'page' ? (
789
+ <div className="min-h-0 flex-1 overflow-y-auto">
790
+ <PostTab
791
+ post={post}
792
+ canEdit={effectiveCanEdit}
793
+ fields={config?.collections?.[postType]?.fields}
794
+ onChange={handleFields}
795
+ />
796
+ </div>
684
797
  ) : (
685
798
  <SeoEditorPane
686
799
  entityType="post"
687
800
  entityId={post.id}
688
801
  slug={post.slug}
689
802
  auditSections={post.sections}
803
+ onAuditChange={setLiveAudit}
690
804
  contentHtml={seoContentHtml}
805
+ title={post.title}
806
+ bodyHtml={post.body}
807
+ featuredImage={post.featuredImage}
691
808
  draftMeta={{
692
809
  seoTitle: post.seoTitle,
693
810
  seoDescription: post.seoDescription,
@@ -698,15 +815,39 @@ export function PostSectionEditor({
698
815
  />
699
816
  )}
700
817
  </div>
818
+
819
+ {historyOpen && (
820
+ <VersionHistoryPanel
821
+ versions={versions}
822
+ loading={versionsLoading}
823
+ error={versionsError}
824
+ selectedIndex={versionIndex}
825
+ onSelect={setVersionIndex}
826
+ onClose={closeHistory}
827
+ onRestore={handleRestoreVersion}
828
+ restoring={restoring}
829
+ canRestore={canRestoreVersion}
830
+ />
831
+ )}
701
832
  </>
702
833
  )}
703
834
  </EditorRail>
704
835
 
705
836
  <div className="flex-1 overflow-hidden">
706
837
  <PostEditorCanvas
707
- sections={post.sections}
838
+ sections={reviewVersion ? reviewVersion.sections : post.sections}
708
839
  header={header}
709
- context={context}
840
+ context={
841
+ reviewVersion
842
+ ? {
843
+ ...context,
844
+ title: reviewVersion.title ?? context.title,
845
+ excerpt: reviewVersion.excerpt ?? context.excerpt,
846
+ body: reviewVersion.body ?? context.body,
847
+ featuredImageUrl: reviewVersion.featuredImage ?? context.featuredImageUrl,
848
+ }
849
+ : context
850
+ }
710
851
  selectedId={selectedId}
711
852
  viewport={viewport}
712
853
  zoom={zoom}
@@ -714,26 +855,30 @@ export function PostSectionEditor({
714
855
  onCanvasElement={setCanvasEl}
715
856
  onSelect={handleSelectSection}
716
857
  structuralTypes={structuralTypes}
858
+ reviewing={reviewing}
859
+ changedSectionIds={reviewChangedIds}
860
+ reviewBar={
861
+ reviewVersion ? (
862
+ <ReviewBar
863
+ version={reviewVersion}
864
+ changedCount={reviewChangedIds.size}
865
+ onRestore={() => void handleRestoreVersion(reviewVersion)}
866
+ onBackToCurrent={() => setVersionIndex(0)}
867
+ restoring={restoring}
868
+ canRestore={canRestoreVersion}
869
+ />
870
+ ) : null
871
+ }
717
872
  />
718
873
  </div>
719
874
  </div>
720
875
  </ReflowContext.Provider>
721
876
  </div>
722
877
 
723
- {post.id && (
724
- <VersionHistory
725
- collectionSlug={postType}
726
- documentId={post.id}
727
- open={historyOpen}
728
- onClose={() => setHistoryOpen(false)}
729
- onRestore={handleRestored}
730
- />
731
- )}
732
-
733
878
  <PostFieldsModal
734
879
  open={fieldsOpen}
735
880
  post={post}
736
- canEdit={canEdit}
881
+ canEdit={effectiveCanEdit}
737
882
  fields={config?.collections?.[postType]?.fields}
738
883
  onClose={() => setFieldsOpen(false)}
739
884
  onSave={handleFields}
@@ -0,0 +1,46 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { EditorPost } from '../../lib/post-editor-service.js'
5
+ import { PostFieldsForm, type PostFieldConfig } from './PostFieldsForm.js'
6
+
7
+ export interface PostTabProps {
8
+ post: EditorPost
9
+ canEdit: boolean
10
+ fields?: Record<string, PostFieldConfig | undefined>
11
+ onChange: (patch: Partial<EditorPost>) => void
12
+ }
13
+
14
+ /**
15
+ * Rail tab 3 — post identity fields only.
16
+ *
17
+ * Title, slug, excerpt, featured image, category, and tags. Layout, header
18
+ * chrome, and template controls stay design-owned on the type template.
19
+ * Address / 301 / robots / author / danger-zone belong to pages, not posts.
20
+ */
21
+ export function PostTab({ post, canEdit, fields, onChange }: PostTabProps) {
22
+ const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
23
+
24
+ return (
25
+ <div className="p-3">
26
+ <PostFieldsForm
27
+ idPrefix="post"
28
+ values={{
29
+ title: post.title,
30
+ slug: post.slug,
31
+ excerpt: post.excerpt,
32
+ featuredImage: post.featuredImage,
33
+ category: post.category,
34
+ tags: post.tags,
35
+ }}
36
+ canEdit={canEdit}
37
+ fields={fields}
38
+ postType={post.postType}
39
+ bodyHtml={post.body}
40
+ slugTouched={slugTouched}
41
+ onSlugTouched={() => setSlugTouched(true)}
42
+ onChange={(patch) => onChange(patch)}
43
+ />
44
+ </div>
45
+ )
46
+ }
@@ -153,7 +153,7 @@ const DEFAULT_FORM: AppearanceForm = {
153
153
  colorMode: 'system',
154
154
  accent: 'purple',
155
155
  density: 'default',
156
- font: 'DM Sans',
156
+ font: 'System UI',
157
157
  reduceMotion: false,
158
158
  }
159
159