@prenta/admin 0.93.0 → 0.95.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 +152 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +37 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
- package/dist/__tests__/lib/version-diff.test.d.ts +2 -0
- package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
- package/dist/__tests__/lib/version-diff.test.js +230 -0
- package/dist/__tests__/lib/version-diff.test.js.map +1 -0
- package/dist/__tests__/lib/version-history.test.d.ts +2 -0
- package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
- package/dist/__tests__/lib/version-history.test.js +125 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
- package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.js +127 -0
- package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +29 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/section-edit-form.render.test.d.ts +2 -0
- package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
- package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
- package/dist/__tests__/views/section-inspector-media.test.js +1 -1
- package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
- package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
- package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
- package/dist/__tests__/views/version-history-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
- package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
- package/dist/components/CanvasEditTip.js +1 -1
- package/dist/components/CanvasEditTip.js.map +1 -1
- package/dist/components/EditorRail.js +3 -3
- package/dist/components/EditorRail.js.map +1 -1
- package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -1
- package/dist/components/page-tab/DirectiveSwitch.js +5 -1
- package/dist/components/page-tab/DirectiveSwitch.js.map +1 -1
- package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
- package/dist/layout/primitives/AdminShell.js +2 -2
- package/dist/layout/primitives/AdminShell.js.map +1 -1
- package/dist/lib/version-diff.d.ts +50 -0
- package/dist/lib/version-diff.d.ts.map +1 -0
- package/dist/lib/version-diff.js +294 -0
- package/dist/lib/version-diff.js.map +1 -0
- package/dist/lib/version-history.d.ts +73 -0
- package/dist/lib/version-history.d.ts.map +1 -0
- package/dist/lib/version-history.js +126 -0
- package/dist/lib/version-history.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts +14 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +3 -3
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +8 -2
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +8 -6
- 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 +129 -40
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/ReviewBar.d.ts +21 -0
- package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
- package/dist/views/page-editor/ReviewBar.js +22 -0
- package/dist/views/page-editor/ReviewBar.js.map +1 -0
- package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
- package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
- package/dist/views/page-editor/SectionEditForm.js +48 -0
- package/dist/views/page-editor/SectionEditForm.js.map +1 -0
- package/dist/views/page-editor/SectionInspector.d.ts +0 -20
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +6 -224
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +11 -5
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/VersionHistoryPanel.d.ts +29 -0
- package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
- package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
- package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
- package/dist/views/page-editor/section-fields.d.ts +43 -0
- package/dist/views/page-editor/section-fields.d.ts.map +1 -0
- package/dist/views/page-editor/section-fields.js +236 -0
- package/dist/views/page-editor/section-fields.js.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts +13 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +23 -8
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +11 -9
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
- package/src/__tests__/lib/version-diff.test.ts +278 -0
- package/src/__tests__/lib/version-history.test.ts +166 -0
- package/src/__tests__/styles/responsive-layer.test.ts +152 -0
- package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -0
- package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
- package/src/__tests__/views/section-inspector-media.test.ts +1 -1
- package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
- package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
- package/src/components/CanvasEditTip.tsx +3 -3
- package/src/components/EditorRail.tsx +5 -5
- package/src/components/page-tab/DirectiveSwitch.tsx +4 -1
- package/src/layout/primitives/AdminShell.tsx +13 -1
- package/src/lib/version-diff.ts +376 -0
- package/src/lib/version-history.ts +192 -0
- package/src/styles/responsive.css +281 -0
- package/src/styles/theme.css +8 -0
- package/src/views/page-editor/EditorCanvas.tsx +34 -7
- package/src/views/page-editor/EditorTopBar.tsx +20 -8
- package/src/views/page-editor/PageSectionEditor.tsx +182 -65
- package/src/views/page-editor/ReviewBar.tsx +76 -0
- package/src/views/page-editor/SectionEditForm.tsx +161 -0
- package/src/views/page-editor/SectionInspector.tsx +9 -553
- package/src/views/page-editor/SectionsPanel.tsx +72 -16
- package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
- package/src/views/page-editor/section-fields.tsx +563 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
- package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
- package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +18 -26
|
@@ -10,7 +10,14 @@ import { SeoEditorPane } from '../../components/seo/SeoEditorPane.js'
|
|
|
10
10
|
import { EditorRail, RailHeader } from '../../components/EditorRail.js'
|
|
11
11
|
import { EditorRailTabs, type RailTab, type RailTabId } from '../../components/EditorRailTabs.js'
|
|
12
12
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
13
|
-
import {
|
|
13
|
+
import { VersionHistoryPanel } from './VersionHistoryPanel.js'
|
|
14
|
+
import { ReviewBar } from './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'
|
|
14
21
|
import {
|
|
15
22
|
emptyPage,
|
|
16
23
|
fetchPageForEditor,
|
|
@@ -22,7 +29,6 @@ import {
|
|
|
22
29
|
updateSectionContent,
|
|
23
30
|
updateSectionContentAtPath,
|
|
24
31
|
updateSectionMeta,
|
|
25
|
-
updateSectionSettings,
|
|
26
32
|
validatePage,
|
|
27
33
|
type EditorPage,
|
|
28
34
|
type ValidationResult,
|
|
@@ -34,7 +40,6 @@ import {
|
|
|
34
40
|
createPreviewToken,
|
|
35
41
|
} from '../../lib/preview-link.js'
|
|
36
42
|
import { usePublicSiteUrl } from '../../hooks/usePublicSiteUrl.js'
|
|
37
|
-
import type { SectionSettings } from './section-types.js'
|
|
38
43
|
import { DEFAULT_VIEWPORT, type ViewportId } from './viewports.js'
|
|
39
44
|
import type { Zoom } from '../../lib/breakpoints.js'
|
|
40
45
|
import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
|
|
@@ -56,7 +61,7 @@ import { EditorCanvas } from './EditorCanvas.js'
|
|
|
56
61
|
import { ReflowContext } from './reflow-context.js'
|
|
57
62
|
import { SitePreviewFrame } from './SitePreviewFrame.js'
|
|
58
63
|
import { useSitePreview } from './useSitePreview.js'
|
|
59
|
-
import {
|
|
64
|
+
import { SectionEditForm } from './SectionEditForm.js'
|
|
60
65
|
import { PageSettingsModal } from './PageSettingsModal.js'
|
|
61
66
|
import { ValidationSummary } from './ValidationSummary.js'
|
|
62
67
|
import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
|
|
@@ -221,6 +226,12 @@ export function PageSectionEditor({
|
|
|
221
226
|
/** Active rail tab. Replaces the old boolean `seoOpen` right-hand aside. */
|
|
222
227
|
const [railTab, setRailTab] = useState<RailTabId>('sections')
|
|
223
228
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
229
|
+
const [versions, setVersions] = useState<PageVersion[]>([])
|
|
230
|
+
const [versionsLoading, setVersionsLoading] = useState(false)
|
|
231
|
+
const [versionsError, setVersionsError] = useState<string | null>(null)
|
|
232
|
+
/** Index into `versions`; 0 is the newest, which is the current content. */
|
|
233
|
+
const [versionIndex, setVersionIndex] = useState(0)
|
|
234
|
+
const [restoring, setRestoring] = useState(false)
|
|
224
235
|
const [confirmPublish, setConfirmPublish] = useState(false)
|
|
225
236
|
const [scheduleOpen, setScheduleOpen] = useState(false)
|
|
226
237
|
const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
|
|
@@ -325,6 +336,105 @@ export function PageSectionEditor({
|
|
|
325
336
|
|
|
326
337
|
const activeRailTab = railTabs.find((tab) => tab.id === railTab) ?? railTabs[0]!
|
|
327
338
|
|
|
339
|
+
// ─── Version history ─────────────────────────────────────────────────
|
|
340
|
+
// History is a MODE, not a dialog: it overlays the rail and drives the
|
|
341
|
+
// canvas, so the version being reviewed is editor state rather than
|
|
342
|
+
// something owned by a panel that can be unmounted.
|
|
343
|
+
|
|
344
|
+
const loadVersions = useCallback(async (pageId: string) => {
|
|
345
|
+
setVersionsLoading(true)
|
|
346
|
+
setVersionsError(null)
|
|
347
|
+
try {
|
|
348
|
+
setVersions(await fetchPageVersions(PAGES_COLLECTION, pageId))
|
|
349
|
+
} catch (err) {
|
|
350
|
+
setVersions([])
|
|
351
|
+
setVersionsError(err instanceof Error ? err.message : 'Failed to load version history')
|
|
352
|
+
} finally {
|
|
353
|
+
setVersionsLoading(false)
|
|
354
|
+
}
|
|
355
|
+
}, [])
|
|
356
|
+
|
|
357
|
+
const openHistory = useCallback(() => {
|
|
358
|
+
if (!page?.id) return
|
|
359
|
+
setHistoryOpen(true)
|
|
360
|
+
// Always land on "current" so opening history never silently swaps the
|
|
361
|
+
// canvas out from under the editor.
|
|
362
|
+
setVersionIndex(0)
|
|
363
|
+
void loadVersions(page.id)
|
|
364
|
+
}, [page?.id, loadVersions])
|
|
365
|
+
|
|
366
|
+
const closeHistory = useCallback(() => {
|
|
367
|
+
setHistoryOpen(false)
|
|
368
|
+
setVersionIndex(0)
|
|
369
|
+
}, [])
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Index 0 is the current content, which the canvas is already showing — so
|
|
373
|
+
* selecting it is "back to current", not a review. Only an older index puts
|
|
374
|
+
* the editor into review mode.
|
|
375
|
+
*/
|
|
376
|
+
const reviewing = historyOpen && versionIndex > 0
|
|
377
|
+
const reviewVersion = reviewing ? (versions[versionIndex] ?? null) : null
|
|
378
|
+
|
|
379
|
+
// What that version's save CHANGED — diffed against the one before it, which
|
|
380
|
+
// is the question the outlines on the canvas answer.
|
|
381
|
+
const reviewChanges = useMemo(
|
|
382
|
+
() =>
|
|
383
|
+
reviewVersion
|
|
384
|
+
? diffVersions(reviewVersion.sections, versions[versionIndex + 1]?.sections)
|
|
385
|
+
: [],
|
|
386
|
+
[reviewVersion, versions, versionIndex],
|
|
387
|
+
)
|
|
388
|
+
const reviewChangedIds = useMemo(() => changedSectionIds(reviewChanges), [reviewChanges])
|
|
389
|
+
|
|
390
|
+
const handleRestoreVersion = useCallback(
|
|
391
|
+
async (version: PageVersion) => {
|
|
392
|
+
if (!page?.id) return
|
|
393
|
+
setRestoring(true)
|
|
394
|
+
try {
|
|
395
|
+
await restorePageVersion(PAGES_COLLECTION, page.id, version.id)
|
|
396
|
+
// Re-read rather than trusting the snapshot we already hold: the
|
|
397
|
+
// restore goes through the normal update path, so hooks and derived
|
|
398
|
+
// fields may have altered what actually landed.
|
|
399
|
+
const restored = await fetchPageForEditor(page.id)
|
|
400
|
+
acceptPage(restored)
|
|
401
|
+
setSelectedId(null)
|
|
402
|
+
setDirty(false)
|
|
403
|
+
setSaveState('idle')
|
|
404
|
+
setValidation(null)
|
|
405
|
+
closeHistory()
|
|
406
|
+
void loadVersions(page.id)
|
|
407
|
+
toast.success('Version restored')
|
|
408
|
+
} catch (err) {
|
|
409
|
+
toast.error(err instanceof Error ? err.message : 'Failed to restore version')
|
|
410
|
+
} finally {
|
|
411
|
+
setRestoring(false)
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
[page?.id, acceptPage, closeHistory, loadVersions],
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
// ─── Edit permission ─────────────────────────────────────────────────
|
|
418
|
+
/**
|
|
419
|
+
* ADR 0002: a Page-Builder-owned page (it has a `_layout`) compiles its
|
|
420
|
+
* sections from the builder layout on every save, so editing them here would
|
|
421
|
+
* be overwritten. The server independently refuses such edits with 409.
|
|
422
|
+
*/
|
|
423
|
+
const readOnly = page?.builderOwned ?? false
|
|
424
|
+
/**
|
|
425
|
+
* The single gate every writing surface reads.
|
|
426
|
+
*
|
|
427
|
+
* Hoisted above the callbacks rather than derived at render time, because
|
|
428
|
+
* the two that were left behind were the ones that mattered: the site
|
|
429
|
+
* preview's in-place edit handlers close over `canEdit` (the raw role) and
|
|
430
|
+
* so kept accepting edits while a past version was under review — and while
|
|
431
|
+
* a builder-owned page was supposedly read-only. The iframe is a second
|
|
432
|
+
* editing surface, and a lock that only covers the rail is not a lock.
|
|
433
|
+
*/
|
|
434
|
+
const effectiveCanEdit = canEdit && !readOnly && !reviewing
|
|
435
|
+
/** Restoring is not editing: it is allowed precisely while reviewing. */
|
|
436
|
+
const canRestoreVersion = canEdit && !readOnly
|
|
437
|
+
|
|
328
438
|
// ─── Load ──────────────────────────────────────────────────────────
|
|
329
439
|
useEffect(() => {
|
|
330
440
|
let cancelled = false
|
|
@@ -396,13 +506,12 @@ export function PageSectionEditor({
|
|
|
396
506
|
[selectedId, mutateSections],
|
|
397
507
|
)
|
|
398
508
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
)
|
|
509
|
+
// NOTE: there is deliberately no `handleSettingsChange` here any more.
|
|
510
|
+
// `settings` holds background, spacing, anchor and CSS class — the template's
|
|
511
|
+
// business, not the editor's (docs/design-owned-sections.md). The values
|
|
512
|
+
// still round-trip through save untouched and the renderers still read them;
|
|
513
|
+
// this editor simply no longer offers to change them. The post TEMPLATE
|
|
514
|
+
// editor keeps the full set, because that is the surface that authors one.
|
|
406
515
|
|
|
407
516
|
const handleMetaChange = useCallback(
|
|
408
517
|
(patch: { name?: string; internalLabel?: string }) => {
|
|
@@ -610,24 +719,6 @@ export function PageSectionEditor({
|
|
|
610
719
|
onNavigate('/pages')
|
|
611
720
|
}, [page?.id, onNavigate])
|
|
612
721
|
|
|
613
|
-
// After a version restore the server has already written the restored
|
|
614
|
-
// data — refetch so the editor reflects it instead of the stale state.
|
|
615
|
-
const handleRestored = useCallback(() => {
|
|
616
|
-
if (!page?.id) return
|
|
617
|
-
fetchPageForEditor(page.id)
|
|
618
|
-
.then((p) => {
|
|
619
|
-
acceptPage(p)
|
|
620
|
-
setSelectedId(null)
|
|
621
|
-
setRailTab('sections')
|
|
622
|
-
setDirty(false)
|
|
623
|
-
setSaveState('idle')
|
|
624
|
-
setValidation(null)
|
|
625
|
-
})
|
|
626
|
-
.catch(() => {
|
|
627
|
-
toast.error('Version restored — refresh the page to see it.')
|
|
628
|
-
})
|
|
629
|
-
}, [page?.id])
|
|
630
|
-
|
|
631
722
|
const handleSeoSaved = useCallback(() => {
|
|
632
723
|
reloadAudit()
|
|
633
724
|
if (!page?.id) return
|
|
@@ -656,25 +747,28 @@ export function PageSectionEditor({
|
|
|
656
747
|
setPendingFocusPath(fieldPath ?? null)
|
|
657
748
|
}, [])
|
|
658
749
|
|
|
750
|
+
// `effectiveCanEdit`, not `canEdit`: the site preview is a second editing
|
|
751
|
+
// surface, so a role check alone let it write through both the version-review
|
|
752
|
+
// lock and the builder-owned lock the rest of the editor honours.
|
|
659
753
|
const handleCanvasEdit = useCallback(
|
|
660
754
|
(edit: { sectionId: string; fieldPath: string; value: string }) => {
|
|
661
|
-
if (!
|
|
755
|
+
if (!effectiveCanEdit) return
|
|
662
756
|
setSuppressSectionsEcho((n) => n + 1)
|
|
663
757
|
mutateSections((s) =>
|
|
664
758
|
updateSectionContentAtPath(s, edit.sectionId, edit.fieldPath, edit.value),
|
|
665
759
|
)
|
|
666
760
|
setSelectedId(edit.sectionId)
|
|
667
761
|
},
|
|
668
|
-
[
|
|
762
|
+
[effectiveCanEdit, mutateSections],
|
|
669
763
|
)
|
|
670
764
|
|
|
671
765
|
const handleCanvasRequestMedia = useCallback(
|
|
672
766
|
(request: { sectionId: string; fieldPath: string }) => {
|
|
673
|
-
if (!
|
|
767
|
+
if (!effectiveCanEdit) return
|
|
674
768
|
setSelectedId(request.sectionId)
|
|
675
769
|
setCanvasMediaTarget(request)
|
|
676
770
|
},
|
|
677
|
-
[
|
|
771
|
+
[effectiveCanEdit],
|
|
678
772
|
)
|
|
679
773
|
|
|
680
774
|
// After a canvas field click, focus the matching inspector input once painted.
|
|
@@ -805,14 +899,12 @@ export function PageSectionEditor({
|
|
|
805
899
|
)
|
|
806
900
|
}
|
|
807
901
|
|
|
808
|
-
|
|
902
|
+
// `readOnly` / `effectiveCanEdit` / `canRestoreVersion` are computed above,
|
|
903
|
+
// with the hooks, so the callbacks defined there share this one gate.
|
|
809
904
|
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
// builder. The server independently refuses such edits with 409.
|
|
814
|
-
const readOnly = page.builderOwned
|
|
815
|
-
const effectiveCanEdit = canEdit && !readOnly
|
|
905
|
+
// The canvas shows the reviewed snapshot, but `page.sections` stays the
|
|
906
|
+
// editor's own content throughout — nothing about review mode writes to it.
|
|
907
|
+
const canvasSections = reviewVersion ? reviewVersion.sections : page.sections
|
|
816
908
|
|
|
817
909
|
// Sections whose type has no registered definition (seed/code-owned, e.g.
|
|
818
910
|
// `opal-*`). They are preserved verbatim on save but can't be edited here.
|
|
@@ -857,7 +949,8 @@ export function PageSectionEditor({
|
|
|
857
949
|
onViewOnSite={page.id ? handleViewOnSite : undefined}
|
|
858
950
|
sitePreview={sitePreview.active}
|
|
859
951
|
onToggleSitePreview={page.id && page.slug ? sitePreview.toggle : undefined}
|
|
860
|
-
|
|
952
|
+
historyOpen={historyOpen}
|
|
953
|
+
onHistory={page.id ? () => (historyOpen ? closeHistory() : openHistory()) : undefined}
|
|
861
954
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
862
955
|
onOpenSeo={handleToggleSeo}
|
|
863
956
|
seoOpen={railTab === 'seo'}
|
|
@@ -926,7 +1019,7 @@ export function PageSectionEditor({
|
|
|
926
1019
|
)}
|
|
927
1020
|
|
|
928
1021
|
<ReflowContext.Provider value={reflowContext}>
|
|
929
|
-
<div className="relative flex flex-1 overflow-hidden">
|
|
1022
|
+
<div className="prenta-editor-body relative flex flex-1 overflow-hidden">
|
|
930
1023
|
<EditorRail collapsedLabel={activeRailTab.label}>
|
|
931
1024
|
{({ width, dragging, collapse }) => (
|
|
932
1025
|
<>
|
|
@@ -951,6 +1044,21 @@ export function PageSectionEditor({
|
|
|
951
1044
|
canEdit={effectiveCanEdit}
|
|
952
1045
|
onSelect={handleSelectSection}
|
|
953
1046
|
onToggleVisibility={handleToggleVisibility}
|
|
1047
|
+
// The editor opens inside the row it belongs to. While
|
|
1048
|
+
// reviewing a past version there is nothing to edit, so
|
|
1049
|
+
// rows stay closed rather than opening a dead form.
|
|
1050
|
+
renderEditor={
|
|
1051
|
+
reviewing
|
|
1052
|
+
? undefined
|
|
1053
|
+
: (section) => (
|
|
1054
|
+
<SectionEditForm
|
|
1055
|
+
section={section}
|
|
1056
|
+
canEdit={effectiveCanEdit}
|
|
1057
|
+
onContentChange={handleContentChange}
|
|
1058
|
+
onMetaChange={handleMetaChange}
|
|
1059
|
+
/>
|
|
1060
|
+
)
|
|
1061
|
+
}
|
|
954
1062
|
/>
|
|
955
1063
|
) : railTab === 'page' ? (
|
|
956
1064
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
@@ -992,6 +1100,23 @@ export function PageSectionEditor({
|
|
|
992
1100
|
/>
|
|
993
1101
|
)}
|
|
994
1102
|
</div>
|
|
1103
|
+
|
|
1104
|
+
{/* Overlays the rail rather than replacing its contents, so
|
|
1105
|
+
closing history returns to exactly the tab and scroll
|
|
1106
|
+
position it was opened from. */}
|
|
1107
|
+
{historyOpen && (
|
|
1108
|
+
<VersionHistoryPanel
|
|
1109
|
+
versions={versions}
|
|
1110
|
+
loading={versionsLoading}
|
|
1111
|
+
error={versionsError}
|
|
1112
|
+
selectedIndex={versionIndex}
|
|
1113
|
+
onSelect={setVersionIndex}
|
|
1114
|
+
onClose={closeHistory}
|
|
1115
|
+
onRestore={handleRestoreVersion}
|
|
1116
|
+
restoring={restoring}
|
|
1117
|
+
canRestore={canRestoreVersion}
|
|
1118
|
+
/>
|
|
1119
|
+
)}
|
|
995
1120
|
</>
|
|
996
1121
|
)}
|
|
997
1122
|
</EditorRail>
|
|
@@ -1012,7 +1137,7 @@ export function PageSectionEditor({
|
|
|
1012
1137
|
/>
|
|
1013
1138
|
) : (
|
|
1014
1139
|
<EditorCanvas
|
|
1015
|
-
sections={
|
|
1140
|
+
sections={canvasSections}
|
|
1016
1141
|
selectedId={selectedId}
|
|
1017
1142
|
breakpoint={viewport}
|
|
1018
1143
|
zoom={zoom}
|
|
@@ -1021,35 +1146,27 @@ export function PageSectionEditor({
|
|
|
1021
1146
|
onSelect={handleSelectSection}
|
|
1022
1147
|
structuralTypes={structuralTypes}
|
|
1023
1148
|
sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
|
|
1149
|
+
reviewing={reviewing}
|
|
1150
|
+
changedSectionIds={reviewChangedIds}
|
|
1151
|
+
reviewBar={
|
|
1152
|
+
reviewVersion ? (
|
|
1153
|
+
<ReviewBar
|
|
1154
|
+
version={reviewVersion}
|
|
1155
|
+
changedCount={reviewChangedIds.size}
|
|
1156
|
+
onRestore={() => void handleRestoreVersion(reviewVersion)}
|
|
1157
|
+
onBackToCurrent={() => setVersionIndex(0)}
|
|
1158
|
+
restoring={restoring}
|
|
1159
|
+
canRestore={canRestoreVersion}
|
|
1160
|
+
/>
|
|
1161
|
+
) : null
|
|
1162
|
+
}
|
|
1024
1163
|
/>
|
|
1025
1164
|
)}
|
|
1026
1165
|
</div>
|
|
1027
|
-
|
|
1028
|
-
{selected && railTab === 'sections' && (
|
|
1029
|
-
<SectionInspector
|
|
1030
|
-
section={selected}
|
|
1031
|
-
canEdit={effectiveCanEdit}
|
|
1032
|
-
onClose={() => setSelectedId(null)}
|
|
1033
|
-
onContentChange={handleContentChange}
|
|
1034
|
-
onSettingsChange={handleSettingsChange}
|
|
1035
|
-
onMetaChange={handleMetaChange}
|
|
1036
|
-
onToggleVisibility={() => handleToggleVisibility(selected.id)}
|
|
1037
|
-
/>
|
|
1038
|
-
)}
|
|
1039
1166
|
</div>
|
|
1040
1167
|
</ReflowContext.Provider>
|
|
1041
1168
|
</div>
|
|
1042
1169
|
|
|
1043
|
-
{page.id && (
|
|
1044
|
-
<VersionHistory
|
|
1045
|
-
collectionSlug="pages"
|
|
1046
|
-
documentId={page.id}
|
|
1047
|
-
open={historyOpen}
|
|
1048
|
-
onClose={() => setHistoryOpen(false)}
|
|
1049
|
-
onRestore={handleRestored}
|
|
1050
|
-
/>
|
|
1051
|
-
)}
|
|
1052
|
-
|
|
1053
1170
|
<PageSettingsModal
|
|
1054
1171
|
open={settingsOpen}
|
|
1055
1172
|
page={page}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { History, Loader2 } from 'lucide-react'
|
|
4
|
+
import { dayLabel, timeLabel, type PageVersion } from '../../lib/version-history.js'
|
|
5
|
+
|
|
6
|
+
export interface ReviewBarProps {
|
|
7
|
+
version: PageVersion
|
|
8
|
+
changedCount: number
|
|
9
|
+
onRestore: () => void
|
|
10
|
+
onBackToCurrent: () => void
|
|
11
|
+
restoring: boolean
|
|
12
|
+
canRestore: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The strip above the canvas while a past version is on it.
|
|
17
|
+
*
|
|
18
|
+
* Occupies the same slot as the editing tip and the structure notice, and
|
|
19
|
+
* replaces both rather than stacking with them: everything that slot normally
|
|
20
|
+
* says is about editing the current page, which is precisely what you cannot
|
|
21
|
+
* do here. It also has to be unmissable — the canvas below looks like a
|
|
22
|
+
* perfectly ordinary editable page, and the only thing separating "reviewing
|
|
23
|
+
* history" from "editing" is this bar.
|
|
24
|
+
*/
|
|
25
|
+
export function ReviewBar({
|
|
26
|
+
version,
|
|
27
|
+
changedCount,
|
|
28
|
+
onRestore,
|
|
29
|
+
onBackToCurrent,
|
|
30
|
+
restoring,
|
|
31
|
+
canRestore,
|
|
32
|
+
}: ReviewBarProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
// `status`, not a bare div: entering review mode is a change of what the
|
|
36
|
+
// whole canvas means, and a screen-reader user gets no equivalent of the
|
|
37
|
+
// amber colour that announces it to everyone else.
|
|
38
|
+
role="status"
|
|
39
|
+
className="border-warning/35 bg-warning/10 text-foreground mx-auto mb-3 flex w-fit max-w-full flex-wrap items-center gap-2.5 rounded-full border py-1.5 pr-1.5 pl-3.5 text-[11.5px]"
|
|
40
|
+
>
|
|
41
|
+
<History className="text-warning h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
42
|
+
<span className="min-w-0">
|
|
43
|
+
Viewing{' '}
|
|
44
|
+
<strong className="font-semibold">
|
|
45
|
+
{dayLabel(version.createdAt)} {timeLabel(version.createdAt)}
|
|
46
|
+
</strong>{' '}
|
|
47
|
+
by <strong className="font-semibold">{version.author.name}</strong>
|
|
48
|
+
{changedCount > 0 && (
|
|
49
|
+
<>
|
|
50
|
+
{' · '}
|
|
51
|
+
{changedCount} section{changedCount === 1 ? '' : 's'} changed
|
|
52
|
+
</>
|
|
53
|
+
)}
|
|
54
|
+
</span>
|
|
55
|
+
|
|
56
|
+
{canRestore && (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={onRestore}
|
|
60
|
+
disabled={restoring}
|
|
61
|
+
className="border-border bg-card text-foreground hover:bg-accent inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
62
|
+
>
|
|
63
|
+
{restoring && <Loader2 className="h-3 w-3 animate-spin" aria-hidden />}
|
|
64
|
+
Restore
|
|
65
|
+
</button>
|
|
66
|
+
)}
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={onBackToCurrent}
|
|
70
|
+
className="border-border bg-card text-foreground hover:bg-accent shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors"
|
|
71
|
+
>
|
|
72
|
+
Back to current
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, Lock } from 'lucide-react'
|
|
4
|
+
import { RichTextField } from '../../fields/RichTextField.js'
|
|
5
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
6
|
+
import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
|
|
7
|
+
import { getSectionType } from './section-types.js'
|
|
8
|
+
|
|
9
|
+
export interface SectionEditFormProps {
|
|
10
|
+
section: PageSection
|
|
11
|
+
canEdit: boolean
|
|
12
|
+
onContentChange: (patch: Record<string, unknown>) => void
|
|
13
|
+
onMetaChange: (patch: { name?: string; internalLabel?: string }) => void
|
|
14
|
+
/** Post body field — shown when an Article Body section is bound to the post field. */
|
|
15
|
+
postBody?: string
|
|
16
|
+
onPostBodyChange?: (html: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The section editor, rendered inline as the body of its row.
|
|
21
|
+
*
|
|
22
|
+
* ── Why inline, and not a docked panel ────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The design puts the form inside the row it belongs to: click a section, it
|
|
25
|
+
* opens underneath. It never had a right-hand panel. The panel that existed
|
|
26
|
+
* here was left over from the pre-handoff editor, and the rail work that
|
|
27
|
+
* introduced the tab strip explicitly retired the OTHER right-hand aside — the
|
|
28
|
+
* SEO one — on the grounds that "the editor has one settings surface rather
|
|
29
|
+
* than two competing ones". This is that same second surface, finally closed.
|
|
30
|
+
*
|
|
31
|
+
* ── Why there is no Appearance group ──────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* This CMS edits words, images and embed code. Background, colour and spacing
|
|
34
|
+
* are the template's business, and `docs/design-owned-sections.md` already
|
|
35
|
+
* says so in as many words: the editor changes "copy, CTAs, and imagery —
|
|
36
|
+
* never layout, order, or styling". The controls contradicted that rule while
|
|
37
|
+
* sitting one panel away from the sentence stating it.
|
|
38
|
+
*
|
|
39
|
+
* The VALUES are untouched. `settings` round-trips through save exactly as
|
|
40
|
+
* before, the renderers keep reading it, and every existing page renders
|
|
41
|
+
* identically — the editor simply stops offering to change it. Removing the
|
|
42
|
+
* controls and dropping the data are very different changes, and this is only
|
|
43
|
+
* the first. The template editor keeps the full set: it is the surface that
|
|
44
|
+
* authors a template, and styling genuinely belongs there.
|
|
45
|
+
*/
|
|
46
|
+
export function SectionEditForm({
|
|
47
|
+
section,
|
|
48
|
+
canEdit,
|
|
49
|
+
onContentChange,
|
|
50
|
+
onMetaChange,
|
|
51
|
+
postBody,
|
|
52
|
+
onPostBodyChange,
|
|
53
|
+
}: SectionEditFormProps) {
|
|
54
|
+
const def = getSectionType(section.sectionType)
|
|
55
|
+
|
|
56
|
+
// Unmanaged types have no registered field schema, so there is nothing to
|
|
57
|
+
// render a form from. Say why rather than showing an empty box.
|
|
58
|
+
if (section.unmanaged) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="border-border bg-muted/40 border-t px-3 py-3">
|
|
61
|
+
<p className="text-muted-foreground flex items-start gap-2 text-xs leading-relaxed">
|
|
62
|
+
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
63
|
+
<span>
|
|
64
|
+
<span className="text-foreground font-medium">Managed outside the editor</span> — this
|
|
65
|
+
section’s type (<code className="font-mono">{section.sectionType}</code>) is not
|
|
66
|
+
registered, so it is preserved on save but edited in code.
|
|
67
|
+
</span>
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bodySource = String(section.content.source ?? 'field')
|
|
74
|
+
const editPostBodyField =
|
|
75
|
+
section.sectionType === 'article-body' &&
|
|
76
|
+
bodySource === 'field' &&
|
|
77
|
+
onPostBodyChange !== undefined
|
|
78
|
+
const contentFields = def?.fields.filter((f) => !(editPostBodyField && f.key === 'body')) ?? []
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="border-border bg-muted/40 border-t px-3 py-3.5">
|
|
82
|
+
<fieldset disabled={!canEdit} className="space-y-3">
|
|
83
|
+
{editPostBodyField && (
|
|
84
|
+
<RichTextField
|
|
85
|
+
label="Body"
|
|
86
|
+
value={postBody ?? ''}
|
|
87
|
+
onChange={onPostBodyChange}
|
|
88
|
+
className="[&_.ProseMirror]:min-h-[240px]"
|
|
89
|
+
helpText="Main article copy — rendered in the canvas preview and on the live site."
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{contentFields.map((field) => (
|
|
94
|
+
<ContentField
|
|
95
|
+
key={field.key}
|
|
96
|
+
id={`sec-${section.id}-${field.key}`}
|
|
97
|
+
field={field}
|
|
98
|
+
value={section.content[field.key]}
|
|
99
|
+
onChange={(v) => onContentChange({ [field.key]: v })}
|
|
100
|
+
onPatch={(patch) => onContentChange(patch)}
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
|
|
104
|
+
{contentFields.length === 0 && !editPostBodyField && (
|
|
105
|
+
<p className="text-muted-foreground text-xs">
|
|
106
|
+
This section has no editable content — it is rendered entirely from the template.
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<details className="group">
|
|
111
|
+
<summary className="text-muted-foreground hover:text-foreground cursor-pointer list-none text-[11px] font-medium tracking-wider uppercase">
|
|
112
|
+
Section settings
|
|
113
|
+
</summary>
|
|
114
|
+
<div className="mt-2.5 space-y-3">
|
|
115
|
+
<div>
|
|
116
|
+
<label className={FIELD_LABEL} htmlFor={`sec-${section.id}-name`}>
|
|
117
|
+
Section name
|
|
118
|
+
</label>
|
|
119
|
+
<input
|
|
120
|
+
id={`sec-${section.id}-name`}
|
|
121
|
+
className={FIELD_INPUT}
|
|
122
|
+
value={section.name}
|
|
123
|
+
onChange={(e) => onMetaChange({ name: e.target.value })}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<div>
|
|
127
|
+
<label className={FIELD_LABEL} htmlFor={`sec-${section.id}-internal`}>
|
|
128
|
+
Internal label
|
|
129
|
+
</label>
|
|
130
|
+
<input
|
|
131
|
+
id={`sec-${section.id}-internal`}
|
|
132
|
+
className={FIELD_INPUT}
|
|
133
|
+
placeholder="Team-only note (optional)"
|
|
134
|
+
value={section.internalLabel ?? ''}
|
|
135
|
+
onChange={(e) => onMetaChange({ internalLabel: e.target.value })}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</details>
|
|
140
|
+
</fieldset>
|
|
141
|
+
|
|
142
|
+
{/* The design's locked note, per section rather than once per rail: this
|
|
143
|
+
is where an editor looks for the control that is missing. */}
|
|
144
|
+
<p className="text-muted-foreground mt-3 flex items-start gap-1.5 text-[11px] leading-relaxed">
|
|
145
|
+
<Lock className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
|
|
146
|
+
<span>
|
|
147
|
+
Background, spacing and layout come from the template.{' '}
|
|
148
|
+
<a
|
|
149
|
+
href="https://github.com/actuate-media/prenta/blob/main/docs/design-owned-sections.md"
|
|
150
|
+
target="_blank"
|
|
151
|
+
rel="noopener noreferrer"
|
|
152
|
+
className="text-brand inline-flex items-center gap-0.5 hover:underline"
|
|
153
|
+
>
|
|
154
|
+
Why
|
|
155
|
+
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
|
|
156
|
+
</a>
|
|
157
|
+
</span>
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|