@prenta/admin 0.95.0 → 0.96.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 (45) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/__tests__/styles/responsive-layer.test.js +2 -2
  3. package/dist/__tests__/styles/responsive-layer.test.js.map +1 -1
  4. package/dist/__tests__/views/canvas-surface-shared.test.d.ts +2 -0
  5. package/dist/__tests__/views/canvas-surface-shared.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/canvas-surface-shared.test.js +53 -0
  7. package/dist/__tests__/views/canvas-surface-shared.test.js.map +1 -0
  8. package/dist/__tests__/views/page-section-editor.test.js +39 -1
  9. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  10. package/dist/__tests__/views/post-section-editor.test.js +56 -1
  11. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  12. package/dist/prenta-admin.css +1 -1
  13. package/dist/views/page-editor/CanvasSurface.d.ts +55 -0
  14. package/dist/views/page-editor/CanvasSurface.d.ts.map +1 -0
  15. package/dist/views/page-editor/CanvasSurface.js +94 -0
  16. package/dist/views/page-editor/CanvasSurface.js.map +1 -0
  17. package/dist/views/page-editor/EditorCanvas.d.ts +5 -28
  18. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  19. package/dist/views/page-editor/EditorCanvas.js +17 -79
  20. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  21. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  22. package/dist/views/page-editor/PageSectionEditor.js +14 -1
  23. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  24. package/dist/views/post-editor/PostEditorCanvas.d.ts +16 -5
  25. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  26. package/dist/views/post-editor/PostEditorCanvas.js +16 -47
  27. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  28. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  29. package/dist/views/post-editor/PostSectionEditor.js +86 -23
  30. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  31. package/package.json +5 -5
  32. package/src/__tests__/styles/responsive-layer.test.ts +2 -2
  33. package/src/__tests__/views/canvas-surface-shared.test.ts +61 -0
  34. package/src/__tests__/views/page-section-editor.test.tsx +58 -1
  35. package/src/__tests__/views/post-section-editor.test.tsx +91 -1
  36. package/src/views/page-editor/CanvasSurface.tsx +171 -0
  37. package/src/views/page-editor/EditorCanvas.tsx +46 -144
  38. package/src/views/page-editor/PageSectionEditor.tsx +13 -1
  39. package/src/views/post-editor/PostEditorCanvas.tsx +54 -74
  40. package/src/views/post-editor/PostSectionEditor.tsx +184 -82
  41. package/dist/components/EditorSeoAside.d.ts +0 -23
  42. package/dist/components/EditorSeoAside.d.ts.map +0 -1
  43. package/dist/components/EditorSeoAside.js +0 -21
  44. package/dist/components/EditorSeoAside.js.map +0 -1
  45. package/src/components/EditorSeoAside.tsx +0 -68
@@ -1,12 +1,13 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
4
3
  import { FileText, Plus } from 'lucide-react'
5
4
  import { CanvasEditTip } from '../../components/CanvasEditTip.js'
5
+ import type { Zoom } from '../../lib/breakpoints.js'
6
6
  import type { PageSection, PostHeaderConfig } from '../../lib/post-editor-service.js'
7
+ import { CanvasScroll, CanvasSurface, useSectionRefs } from '../page-editor/CanvasSurface.js'
7
8
  import { SectionRenderer, type PostRenderContext } from '../page-editor/sections/index.js'
8
9
  import { StructureNotice } from '../page-editor/StructureNotice.js'
9
- import { getViewport, type ViewportId } from '../page-editor/viewports.js'
10
+ import { type ViewportId } from '../page-editor/viewports.js'
10
11
  import { PostHeader } from './PostHeader.js'
11
12
 
12
13
  interface PostEditorCanvasProps {
@@ -15,7 +16,10 @@ interface PostEditorCanvasProps {
15
16
  context: PostRenderContext
16
17
  selectedId: string | null
17
18
  viewport: ViewportId
19
+ zoom?: Zoom
18
20
  onSelect: (id: string) => void
21
+ onScaleChange?: (scale: number) => void
22
+ onCanvasElement?: (el: HTMLElement | null) => void
19
23
  /** Add-section CTA on the empty canvas. Omitted by design-owned editors. */
20
24
  onAddSection?: () => void
21
25
  /** Section types rendered as placeholders — shows the "Structure view" notice. */
@@ -25,10 +29,17 @@ interface PostEditorCanvasProps {
25
29
  }
26
30
 
27
31
  /**
28
- * Post preview canvas. Mirrors the Page editor canvas (scaled surface that
29
- * fits the selected viewport) but renders the post header above the section
30
- * list so the editor shows the full post layout header regions plus body
31
- * sections exactly as it will ship.
32
+ * Post preview canvas.
33
+ *
34
+ * Shares {@link CanvasSurface} with the page editor, so measurement, scaling,
35
+ * the fit box and the meta line are one implementation rather than two. What is
36
+ * post-specific is what goes inside: the post header renders above the body
37
+ * sections, so the editor shows the full layout — header regions plus sections
38
+ * — exactly as it will ship.
39
+ *
40
+ * Before this shared surface the post canvas derived its width from
41
+ * `container.clientWidth - 48`, which disagrees with the canvas's own metrics
42
+ * once a scrollbar appears, and it had no breakpoint/scale readout at all.
32
43
  */
33
44
  export function PostEditorCanvas({
34
45
  sections,
@@ -36,82 +47,51 @@ export function PostEditorCanvas({
36
47
  context,
37
48
  selectedId,
38
49
  viewport,
50
+ zoom = 'fit',
39
51
  onSelect,
52
+ onScaleChange,
53
+ onCanvasElement,
40
54
  onAddSection,
41
55
  structuralTypes = [],
42
56
  sitePreviewAvailable = false,
43
57
  }: PostEditorCanvasProps) {
44
- const vp = getViewport(viewport)
45
- const containerRef = useRef<HTMLDivElement>(null)
46
- const surfaceRef = useRef<HTMLDivElement>(null)
47
- const sectionRefs = useRef<Map<string, HTMLElement>>(new Map())
48
- const [scale, setScale] = useState(1)
49
- const [surfaceHeight, setSurfaceHeight] = useState<number | null>(null)
50
-
51
- const registerRef = useCallback((id: string, el: HTMLElement | null) => {
52
- if (el) sectionRefs.current.set(id, el)
53
- else sectionRefs.current.delete(id)
54
- }, [])
55
-
56
- useLayoutEffect(() => {
57
- const container = containerRef.current
58
- if (!container) return
59
- const recompute = () => {
60
- const available = container.clientWidth - 48
61
- const next = Math.min(1, available / vp.width)
62
- setScale(next > 0 ? next : 1)
63
- if (surfaceRef.current) {
64
- setSurfaceHeight(surfaceRef.current.offsetHeight * (next > 0 ? next : 1))
65
- }
66
- }
67
- recompute()
68
- const ro = new ResizeObserver(recompute)
69
- ro.observe(container)
70
- if (surfaceRef.current) ro.observe(surfaceRef.current)
71
- return () => ro.disconnect()
72
- }, [vp.width, sections, header, context])
73
-
74
- useEffect(() => {
75
- if (!selectedId) return
76
- const el = sectionRefs.current.get(selectedId)
77
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
78
- }, [selectedId])
58
+ const registerRef = useSectionRefs(selectedId)
79
59
 
80
60
  return (
81
- <div ref={containerRef} className="bg-muted h-full overflow-auto p-6">
82
- {sections.length > 0 && <CanvasEditTip />}
83
- <StructureNotice
84
- structuralTypes={structuralTypes}
85
- sitePreviewAvailable={sitePreviewAvailable}
86
- />
87
- <div
88
- className="mx-auto"
89
- style={{ width: vp.width * scale, height: surfaceHeight ?? undefined }}
61
+ <CanvasScroll>
62
+ <CanvasSurface
63
+ breakpoint={viewport}
64
+ zoom={zoom}
65
+ onScaleChange={onScaleChange}
66
+ onCanvasElement={onCanvasElement}
67
+ chrome={
68
+ <>
69
+ {sections.length > 0 && <CanvasEditTip />}
70
+ <StructureNotice
71
+ structuralTypes={structuralTypes}
72
+ sitePreviewAvailable={sitePreviewAvailable}
73
+ />
74
+ </>
75
+ }
90
76
  >
91
- <div
92
- ref={surfaceRef}
93
- className="prenta-canvas border-border bg-card origin-top overflow-hidden rounded-xl border shadow-sm"
94
- style={{ width: vp.width, transform: `scale(${scale})`, transformOrigin: 'top left' }}
95
- >
96
- <PostHeader config={header} context={context} />
97
- {sections.length === 0 ? (
98
- <EmptyBody onAddSection={onAddSection} />
99
- ) : (
100
- sections.map((section) => (
101
- <SectionRenderer
102
- key={section.id}
103
- section={section}
104
- context={context}
105
- editable
106
- selected={section.id === selectedId}
107
- onSelect={onSelect}
108
- registerRef={registerRef}
109
- />
110
- ))
111
- )}
112
- </div>
113
- </div>
114
- </div>
77
+ <PostHeader config={header} context={context} />
78
+ {sections.length === 0 ? (
79
+ <EmptyBody onAddSection={onAddSection} />
80
+ ) : (
81
+ sections.map((section) => (
82
+ <SectionRenderer
83
+ key={section.id}
84
+ section={section}
85
+ context={context}
86
+ editable
87
+ selected={section.id === selectedId}
88
+ onSelect={onSelect}
89
+ registerRef={registerRef}
90
+ />
91
+ ))
92
+ )}
93
+ </CanvasSurface>
94
+ </CanvasScroll>
115
95
  )
116
96
  }
117
97
 
@@ -6,7 +6,9 @@ import { toast } from 'sonner'
6
6
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
7
7
  import { useNavigationBlocker } from '../../router/index.js'
8
8
  import { SchedulePublishDialog } from '../../components/SchedulePublishDialog.js'
9
- import { EditorSeoAside } from '../../components/EditorSeoAside.js'
9
+ import { EditorRail, RailHeader } from '../../components/EditorRail.js'
10
+ import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
11
+ import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
10
12
  import { ErrorBoundary } from '../../components/ErrorBoundary.js'
11
13
  import { VersionHistory } from '../../components/VersionHistory.js'
12
14
  import {
@@ -42,11 +44,14 @@ import { DEFAULT_VIEWPORT, type ViewportId } from '../page-editor/viewports.js'
42
44
  import { EditorTopBar, type SaveState } from '../page-editor/EditorTopBar.js'
43
45
  import { SitePreviewFrame } from '../page-editor/SitePreviewFrame.js'
44
46
  import { useSitePreview } from '../page-editor/useSitePreview.js'
45
- import { SectionsPanel } from '../page-editor/SectionsPanel.js'
47
+ import { SectionsList } from '../page-editor/SectionsPanel.js'
46
48
  import { SectionEditForm } from '../page-editor/SectionEditForm.js'
47
49
  import { ValidationSummary } from '../page-editor/ValidationSummary.js'
48
50
  import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
49
- import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
51
+ import { buildEditorContentHtml, computeLiveSeoScore } from '../../lib/editor-seo-content.js'
52
+ import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
53
+ import { ReflowContext } from '../page-editor/reflow-context.js'
54
+ import type { Zoom } from '../../lib/breakpoints.js'
50
55
  import { companionDimensionPaths, inspectorIdFromFieldPath } from '../../lib/preview-bridge.js'
51
56
  import { PostEditorCanvas } from './PostEditorCanvas.js'
52
57
  import { PostFieldsModal, type PostFieldConfig } from './PostFieldsModal.js'
@@ -124,18 +129,45 @@ export function PostSectionEditor({
124
129
  fieldPath: string
125
130
  } | null>(null)
126
131
  const [viewport, setViewport] = useState<ViewportId>(DEFAULT_VIEWPORT)
132
+ const [zoom, setZoom] = useState<Zoom>('fit')
133
+ /** Scale the canvas measured — reported upward so the zoom chip shows a real %. */
134
+ const [canvasScale, setCanvasScale] = useState(1)
135
+ /** The live canvas surface, for the reflow guard to clone. */
136
+ const [canvasEl, setCanvasEl] = useState<HTMLElement | null>(null)
137
+ /** Section content as it is on the live site, for the reflow guard's baseline. */
138
+ const [baseline, setBaseline] = useState<ReflowBaseline | null>(null)
127
139
  const [dirty, setDirty] = useState(false)
128
140
  const [saveState, setSaveState] = useState<SaveState>('idle')
129
141
  const [publishing, setPublishing] = useState(false)
130
142
 
131
143
  const [fieldsOpen, setFieldsOpen] = useState(false)
132
- const [seoOpen, setSeoOpen] = useState(false)
144
+ /**
145
+ * Active rail tab. Replaces the boolean `seoOpen` that drove a right-hand
146
+ * aside — the same aside the page editor retired so the editor has one
147
+ * settings surface rather than two competing ones.
148
+ */
149
+ const [railTab, setRailTab] = useState<RailTabId>('sections')
150
+ const seoOpen = railTab === 'seo'
133
151
  const [historyOpen, setHistoryOpen] = useState(false)
134
152
  const [confirmPublish, setConfirmPublish] = useState(false)
135
153
  const [scheduleOpen, setScheduleOpen] = useState(false)
136
154
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
137
155
  const [validation, setValidation] = useState<ValidationResult | null>(null)
138
156
 
157
+ /**
158
+ * Take delivery of server state.
159
+ *
160
+ * Every path that receives a fresh post from the API goes through here so
161
+ * the reflow baseline is re-captured with it. Saving a PUBLISHED post writes
162
+ * straight through to the live content, so a save moves the baseline exactly
163
+ * as a publish does; missing one would leave the guard comparing against
164
+ * content no longer on the site.
165
+ */
166
+ const acceptPost = useCallback((p: EditorPost) => {
167
+ setPost(p)
168
+ setBaseline(snapshotBaseline(p.sections, p.status))
169
+ }, [])
170
+
139
171
  const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
140
172
  const customRenderers = useAdminSectionRenderers()
141
173
  const { siteUrl: publicSiteUrl } = usePublicSiteUrl(config?.seo?.siteUrl)
@@ -166,6 +198,45 @@ export function PostSectionEditor({
166
198
  )
167
199
  const publishReady = Boolean(post) && publishCheck.errors.length === 0
168
200
 
201
+ const railTabs = useMemo<RailTab[]>(
202
+ () => [
203
+ {
204
+ id: 'sections',
205
+ label: 'Sections',
206
+ title: 'Post content',
207
+ count: post?.sections.length ?? 0,
208
+ },
209
+ // No score dot yet. The page editor's comes from the page audit, which
210
+ // is page-scoped; wiring a number here from `computeLiveSeoScore` would
211
+ // put a different model's score on a tab, which is the disagreement the
212
+ // page editor was rewired to remove.
213
+ { id: 'seo', label: 'SEO & GEO', title: 'Search & AI readiness' },
214
+ ],
215
+ [post?.sections.length],
216
+ )
217
+ const activeRailTab = railTabs.find((tab) => tab.id === railTab) ?? railTabs[0]!
218
+
219
+ // Content the SEO pane analyses — sections plus the post body.
220
+ const seoContentHtml = useMemo(
221
+ () => buildEditorContentHtml(post?.sections ?? [], post?.body ?? ''),
222
+ [post?.sections, post?.body],
223
+ )
224
+
225
+ // The reflow guard's inputs, gathered in one place. Memoised because a new
226
+ // object identity on every render would restart the debounce in every guarded
227
+ // field's effect — the measurement would then never settle while typing,
228
+ // which is precisely when it is wanted.
229
+ const reflowContext = useMemo(
230
+ () => ({
231
+ canvas: canvasEl,
232
+ sectionId: selectedId,
233
+ baseline,
234
+ device: viewport,
235
+ onProof: setViewport,
236
+ }),
237
+ [canvasEl, selectedId, baseline, viewport],
238
+ )
239
+
169
240
  const liveSeoScore = useMemo(() => {
170
241
  if (!post) return 0
171
242
  return computeLiveSeoScore({
@@ -210,7 +281,7 @@ export function PostSectionEditor({
210
281
  load
211
282
  .then((p) => {
212
283
  if (cancelled) return
213
- setPost(p)
284
+ acceptPost(p)
214
285
  setSelectedId(null)
215
286
  if (!documentId) {
216
287
  setDirty(true)
@@ -305,7 +376,7 @@ export function PostSectionEditor({
305
376
  setSaveState('saving')
306
377
  try {
307
378
  const saved = await savePostDraft(post)
308
- setPost(saved)
379
+ acceptPost(saved)
309
380
  setDirty(false)
310
381
  setSaveState('saved')
311
382
  setValidation(null)
@@ -387,7 +458,7 @@ export function PostSectionEditor({
387
458
  setPublishing(true)
388
459
  try {
389
460
  const published = await publishPost(post)
390
- setPost(published)
461
+ acceptPost(published)
391
462
  setDirty(false)
392
463
  setSaveState('saved')
393
464
  setValidation(null)
@@ -406,9 +477,9 @@ export function PostSectionEditor({
406
477
  if (!post?.id) return
407
478
  fetchPostForEditor(postType, post.id)
408
479
  .then((p) => {
409
- setPost(p)
480
+ acceptPost(p)
410
481
  setSelectedId(null)
411
- setSeoOpen(false)
482
+ setRailTab('sections')
412
483
  setDirty(false)
413
484
  setSaveState('idle')
414
485
  setValidation(null)
@@ -421,23 +492,27 @@ export function PostSectionEditor({
421
492
  const handleSeoSaved = useCallback(() => {
422
493
  if (!post?.id) return
423
494
  fetchPostForEditor(postType, post.id)
424
- .then((p) => setPost(p))
495
+ .then((p) => acceptPost(p))
425
496
  .catch(() => {
426
497
  /* SEO API saved; editor meta will sync on next full load */
427
498
  })
428
499
  }, [postType, post?.id])
429
500
 
430
501
  const handleToggleSeo = useCallback(() => {
431
- setSeoOpen((open) => {
432
- if (!open) setSelectedId(null)
433
- return !open
502
+ setRailTab((tab) => {
503
+ if (tab === 'seo') return 'sections'
504
+ setSelectedId(null)
505
+ return 'seo'
434
506
  })
435
507
  }, [])
436
508
 
437
509
  const handleSelectSection = useCallback((id: string | null, fieldPath?: string) => {
438
- setSeoOpen(false)
439
- setSelectedId(id)
510
+ setRailTab('sections')
440
511
  setPendingFocusPath(fieldPath ?? null)
512
+ // Toggles, because selection is now disclosure — see the note on the page
513
+ // editor's copy. A `fieldPath` means the site preview is pointing at a
514
+ // specific field, which must open rather than toggle.
515
+ setSelectedId((current) => (fieldPath ? id : current === id ? null : id))
441
516
  }, [])
442
517
 
443
518
  const handleCanvasEdit = useCallback(
@@ -618,6 +693,11 @@ export function PostSectionEditor({
618
693
  viewport={viewport}
619
694
  backLabel="Posts"
620
695
  onViewport={setViewport}
696
+ {...(sitePreview.active
697
+ ? // The site preview is an iframe of the real front end, not a
698
+ // scaled canvas — a zoom percentage there would be a lie.
699
+ {}
700
+ : { zoom, canvasScale, onZoom: setZoom })}
621
701
  onBack={() => guardedNavigate('/posts')}
622
702
  onPreview={handlePreview}
623
703
  onViewOnSite={post.id ? handleViewOnSite : undefined}
@@ -662,74 +742,96 @@ export function PostSectionEditor({
662
742
  />
663
743
  )}
664
744
 
665
- <div className="relative flex flex-1 overflow-hidden">
666
- <SectionsPanel
667
- sections={post.sections}
668
- selectedId={selectedId}
669
- canEdit={canEdit}
670
- onSelect={handleSelectSection}
671
- onToggleVisibility={handleToggleVisibility}
672
- // Same inline editor as the page editor. The post editor still has
673
- // its own standalone panel rather than the rail (followup 20), but
674
- // where the FORM lives is a separate question from what contains
675
- // the list, and the answer is the same on both screens.
676
- renderEditor={(section) => (
677
- <SectionEditForm
678
- section={section}
679
- canEdit={canEdit}
680
- onContentChange={handleContentChange}
681
- onMetaChange={handleMetaChange}
682
- postBody={post.body}
683
- onPostBodyChange={handlePostBodyChange}
684
- />
685
- )}
686
- />
687
-
688
- <div className="flex-1 overflow-hidden">
689
- {sitePreview.active ? (
690
- <SitePreviewFrame
691
- src={sitePreview.url}
692
- viewport={viewport}
693
- reloadKey={sitePreview.reloadKey}
694
- title={`Site preview: ${post.title || `New ${typeLabel}`}`}
695
- selectedId={selectedId}
696
- sections={post.sections}
697
- suppressSectionsEcho={suppressSectionsEcho}
698
- onSelectSection={handleSelectSection}
699
- onCanvasEdit={handleCanvasEdit}
700
- onCanvasRequestMedia={handleCanvasRequestMedia}
701
- />
702
- ) : (
703
- <PostEditorCanvas
704
- sections={post.sections}
705
- header={header}
706
- context={context}
707
- selectedId={selectedId}
708
- viewport={viewport}
709
- onSelect={handleSelectSection}
710
- structuralTypes={structuralTypes}
711
- sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
712
- />
713
- )}
745
+ <ReflowContext.Provider value={reflowContext}>
746
+ <div className="prenta-editor-body relative flex flex-1 overflow-hidden">
747
+ <EditorRail collapsedLabel={activeRailTab.label}>
748
+ {({ width, dragging, collapse }) => (
749
+ <>
750
+ <EditorRailTabs tabs={railTabs} active={railTab} onChange={setRailTab} />
751
+ <RailHeader
752
+ title={activeRailTab.title}
753
+ width={width}
754
+ dragging={dragging}
755
+ onCollapse={collapse}
756
+ />
757
+
758
+ <div
759
+ role="tabpanel"
760
+ id={`rail-panel-${railTab}`}
761
+ aria-labelledby={`rail-tab-${railTab}`}
762
+ className="flex min-h-0 flex-1 flex-col"
763
+ >
764
+ {railTab === 'sections' ? (
765
+ <SectionsList
766
+ sections={post.sections}
767
+ selectedId={selectedId}
768
+ canEdit={canEdit}
769
+ onSelect={handleSelectSection}
770
+ onToggleVisibility={handleToggleVisibility}
771
+ renderEditor={(section) => (
772
+ <SectionEditForm
773
+ section={section}
774
+ canEdit={canEdit}
775
+ onContentChange={handleContentChange}
776
+ onMetaChange={handleMetaChange}
777
+ postBody={post.body}
778
+ onPostBodyChange={handlePostBodyChange}
779
+ />
780
+ )}
781
+ />
782
+ ) : (
783
+ <SeoEditorPane
784
+ entityType="post"
785
+ entityId={post.id}
786
+ slug={post.slug}
787
+ auditSections={post.sections}
788
+ contentHtml={seoContentHtml}
789
+ draftMeta={{
790
+ seoTitle: post.seoTitle,
791
+ seoDescription: post.seoDescription,
792
+ }}
793
+ onDraftChange={handleFields}
794
+ onClose={() => setRailTab('sections')}
795
+ onSaved={handleSeoSaved}
796
+ />
797
+ )}
798
+ </div>
799
+ </>
800
+ )}
801
+ </EditorRail>
802
+
803
+ <div className="flex-1 overflow-hidden">
804
+ {sitePreview.active ? (
805
+ <SitePreviewFrame
806
+ src={sitePreview.url}
807
+ viewport={viewport}
808
+ reloadKey={sitePreview.reloadKey}
809
+ title={`Site preview: ${post.title || `New ${typeLabel}`}`}
810
+ selectedId={selectedId}
811
+ sections={post.sections}
812
+ suppressSectionsEcho={suppressSectionsEcho}
813
+ onSelectSection={handleSelectSection}
814
+ onCanvasEdit={handleCanvasEdit}
815
+ onCanvasRequestMedia={handleCanvasRequestMedia}
816
+ />
817
+ ) : (
818
+ <PostEditorCanvas
819
+ sections={post.sections}
820
+ header={header}
821
+ context={context}
822
+ selectedId={selectedId}
823
+ viewport={viewport}
824
+ zoom={zoom}
825
+ onScaleChange={setCanvasScale}
826
+ onCanvasElement={setCanvasEl}
827
+ onSelect={handleSelectSection}
828
+ structuralTypes={structuralTypes}
829
+ sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
830
+ />
831
+ )}
832
+ </div>
714
833
  </div>
715
-
716
- {seoOpen && (
717
- <EditorSeoAside
718
- entityType="post"
719
- entityId={post.id}
720
- title={post.title}
721
- slug={post.slug}
722
- seoTitle={post.seoTitle}
723
- seoDescription={post.seoDescription}
724
- sections={post.sections}
725
- bodyHtml={post.body}
726
- canEdit={canEdit}
727
- onClose={() => setSeoOpen(false)}
728
- onDraftChange={handleFields}
729
- onSaved={handleSeoSaved}
730
- />
731
- )}
732
- </div>
834
+ </ReflowContext.Provider>
733
835
  </div>
734
836
 
735
837
  {post.id && (
@@ -1,23 +0,0 @@
1
- import type { PageSection } from '@prenta/core/page-sections';
2
- export interface EditorSeoAsideProps {
3
- entityType: 'page' | 'post';
4
- /** Saved document id — when absent, draft mode with live analysis. */
5
- entityId: string | null;
6
- title: string;
7
- slug: string;
8
- seoTitle: string;
9
- seoDescription: string;
10
- /** Post body HTML and/or section content for live SEO analysis. */
11
- sections?: PageSection[];
12
- bodyHtml?: string;
13
- canEdit: boolean;
14
- onClose: () => void;
15
- onDraftChange?: (patch: {
16
- seoTitle?: string;
17
- seoDescription?: string;
18
- }) => void;
19
- onSaved?: () => void;
20
- }
21
- /** Right-side SEO panel for the Page / Post section editors. */
22
- export declare function EditorSeoAside({ entityType, entityId, slug, seoTitle, seoDescription, sections, bodyHtml, onClose, onDraftChange, onSaved, }: EditorSeoAsideProps): import("react/jsx-runtime").JSX.Element;
23
- //# sourceMappingURL=EditorSeoAside.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"EditorSeoAside.d.ts","sourceRoot":"","sources":["../../src/components/EditorSeoAside.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAI7D,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAA;IAC3B,sEAAsE;IACtE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,cAAc,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,WAAW,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;IAC/E,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;CACrB;AAED,gEAAgE;AAChE,wBAAgB,cAAc,CAAC,EAC7B,UAAU,EACV,QAAQ,EACR,IAAI,EACJ,QAAQ,EACR,cAAc,EACd,QAAa,EACb,QAAa,EACb,OAAO,EACP,aAAa,EACb,OAAO,GACR,EAAE,mBAAmB,2CA4BrB"}
@@ -1,21 +0,0 @@
1
- 'use client';
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useMemo } from 'react';
4
- import { SeoEditorPane } from './seo/SeoEditorPane.js';
5
- import { ResizableAside } from './ui/ResizableAside.js';
6
- import { buildEditorContentHtml } from '../lib/editor-seo-content.js';
7
- const INSPECTOR_SHELL = 'bg-card border-border flex h-full flex-col border-l';
8
- /** Right-side SEO panel for the Page / Post section editors. */
9
- export function EditorSeoAside({ entityType, entityId, slug, seoTitle, seoDescription, sections = [], bodyHtml = '', onClose, onDraftChange, onSaved, }) {
10
- const contentHtml = useMemo(() => buildEditorContentHtml(sections, bodyHtml), [sections, bodyHtml]);
11
- useEffect(() => {
12
- function onKey(e) {
13
- if (e.key === 'Escape')
14
- onClose();
15
- }
16
- document.addEventListener('keydown', onKey);
17
- return () => document.removeEventListener('keydown', onKey);
18
- }, [onClose]);
19
- return (_jsx(ResizableAside, { role: "dialog", "aria-label": "SEO settings", className: INSPECTOR_SHELL, children: _jsx(SeoEditorPane, { entityType: entityType, entityId: entityId, slug: slug, contentHtml: contentHtml, draftMeta: { seoTitle, seoDescription }, onDraftChange: onDraftChange, onClose: onClose, onSaved: onSaved }) }));
20
- }
21
- //# sourceMappingURL=EditorSeoAside.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"EditorSeoAside.js","sourceRoot":"","sources":["../../src/components/EditorSeoAside.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,OAAO,CAAA;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AACvD,OAAO,EAAE,sBAAsB,EAAE,MAAM,8BAA8B,CAAA;AAGrE,MAAM,eAAe,GAAG,qDAAqD,CAAA;AAmB7E,gEAAgE;AAChE,MAAM,UAAU,cAAc,CAAC,EAC7B,UAAU,EACV,QAAQ,EACR,IAAI,EACJ,QAAQ,EACR,cAAc,EACd,QAAQ,GAAG,EAAE,EACb,QAAQ,GAAG,EAAE,EACb,OAAO,EACP,aAAa,EACb,OAAO,GACa;IACpB,MAAM,WAAW,GAAG,OAAO,CACzB,GAAG,EAAE,CAAC,sBAAsB,CAAC,QAAQ,EAAE,QAAQ,CAAC,EAChD,CAAC,QAAQ,EAAE,QAAQ,CAAC,CACrB,CAAA;IAED,SAAS,CAAC,GAAG,EAAE;QACb,SAAS,KAAK,CAAC,CAAgB;YAC7B,IAAI,CAAC,CAAC,GAAG,KAAK,QAAQ;gBAAE,OAAO,EAAE,CAAA;QACnC,CAAC;QACD,QAAQ,CAAC,gBAAgB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAA;QAC3C,OAAO,GAAG,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC,SAAS,EAAE,KAAK,CAAC,CAAA;IAC7D,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAA;IAEb,OAAO,CACL,KAAC,cAAc,IAAC,IAAI,EAAC,QAAQ,gBAAY,cAAc,EAAC,SAAS,EAAE,eAAe,YAChF,KAAC,aAAa,IACZ,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,IAAI,EACV,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,EAAE,QAAQ,EAAE,cAAc,EAAE,EACvC,aAAa,EAAE,aAAa,EAC5B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,OAAO,GAChB,GACa,CAClB,CAAA;AACH,CAAC"}
@@ -1,68 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useMemo } from 'react'
4
- import { SeoEditorPane } from './seo/SeoEditorPane.js'
5
- import { ResizableAside } from './ui/ResizableAside.js'
6
- import { buildEditorContentHtml } from '../lib/editor-seo-content.js'
7
- import type { PageSection } from '@prenta/core/page-sections'
8
-
9
- const INSPECTOR_SHELL = 'bg-card border-border flex h-full flex-col border-l'
10
-
11
- export interface EditorSeoAsideProps {
12
- entityType: 'page' | 'post'
13
- /** Saved document id — when absent, draft mode with live analysis. */
14
- entityId: string | null
15
- title: string
16
- slug: string
17
- seoTitle: string
18
- seoDescription: string
19
- /** Post body HTML and/or section content for live SEO analysis. */
20
- sections?: PageSection[]
21
- bodyHtml?: string
22
- canEdit: boolean
23
- onClose: () => void
24
- onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
25
- onSaved?: () => void
26
- }
27
-
28
- /** Right-side SEO panel for the Page / Post section editors. */
29
- export function EditorSeoAside({
30
- entityType,
31
- entityId,
32
- slug,
33
- seoTitle,
34
- seoDescription,
35
- sections = [],
36
- bodyHtml = '',
37
- onClose,
38
- onDraftChange,
39
- onSaved,
40
- }: EditorSeoAsideProps) {
41
- const contentHtml = useMemo(
42
- () => buildEditorContentHtml(sections, bodyHtml),
43
- [sections, bodyHtml],
44
- )
45
-
46
- useEffect(() => {
47
- function onKey(e: KeyboardEvent) {
48
- if (e.key === 'Escape') onClose()
49
- }
50
- document.addEventListener('keydown', onKey)
51
- return () => document.removeEventListener('keydown', onKey)
52
- }, [onClose])
53
-
54
- return (
55
- <ResizableAside role="dialog" aria-label="SEO settings" className={INSPECTOR_SHELL}>
56
- <SeoEditorPane
57
- entityType={entityType}
58
- entityId={entityId}
59
- slug={slug}
60
- contentHtml={contentHtml}
61
- draftMeta={{ seoTitle, seoDescription }}
62
- onDraftChange={onDraftChange}
63
- onClose={onClose}
64
- onSaved={onSaved}
65
- />
66
- </ResizableAside>
67
- )
68
- }