@prenta/admin 1.9.1 → 1.10.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 +10 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +2 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/assets/prenta-logo.test.d.ts +2 -0
- package/dist/__tests__/assets/prenta-logo.test.d.ts.map +1 -0
- package/dist/__tests__/assets/prenta-logo.test.js +26 -0
- package/dist/__tests__/assets/prenta-logo.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-brand.render.test.js +13 -0
- package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -1
- package/dist/__tests__/lib/theme-chrome.test.d.ts +2 -0
- package/dist/__tests__/lib/theme-chrome.test.d.ts.map +1 -0
- package/dist/__tests__/lib/theme-chrome.test.js +104 -0
- package/dist/__tests__/lib/theme-chrome.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +11 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/login-branding.render.test.js +2 -1
- package/dist/__tests__/views/login-branding.render.test.js.map +1 -1
- package/dist/__tests__/views/theme-chrome-frame.render.test.d.ts +2 -0
- package/dist/__tests__/views/theme-chrome-frame.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/theme-chrome-frame.render.test.js +236 -0
- package/dist/__tests__/views/theme-chrome-frame.render.test.js.map +1 -0
- package/dist/assets/prenta-logo.d.ts +15 -7
- package/dist/assets/prenta-logo.d.ts.map +1 -1
- package/dist/assets/prenta-logo.js +12 -4
- package/dist/assets/prenta-logo.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +2 -12
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/theme-chrome.d.ts +87 -0
- package/dist/lib/theme-chrome.d.ts.map +1 -0
- package/dist/lib/theme-chrome.js +117 -0
- package/dist/lib/theme-chrome.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/views/Login.d.ts +1 -1
- package/dist/views/Login.d.ts.map +1 -1
- package/dist/views/Login.js +4 -3
- package/dist/views/Login.js.map +1 -1
- package/dist/views/page-editor/CanvasSurface.d.ts +14 -2
- package/dist/views/page-editor/CanvasSurface.d.ts.map +1 -1
- package/dist/views/page-editor/CanvasSurface.js +30 -14
- package/dist/views/page-editor/CanvasSurface.js.map +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts +12 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +22 -5
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +5 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +3 -2
- 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 +30 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.d.ts +21 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.d.ts.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.js +34 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.js.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.d.ts +12 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.d.ts.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.js +12 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.js.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.d.ts +11 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.d.ts.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.js +15 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.js.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.d.ts +18 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.d.ts.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.js +23 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.js.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.d.ts +11 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.d.ts.map +1 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.js +82 -0
- package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.js.map +1 -0
- package/dist/views/page-editor/theme-chrome/index.d.ts +9 -0
- package/dist/views/page-editor/theme-chrome/index.d.ts.map +1 -0
- package/dist/views/page-editor/theme-chrome/index.js +6 -0
- package/dist/views/page-editor/theme-chrome/index.js.map +1 -0
- package/dist/views/page-editor/theme-chrome/useThemeChromeMode.d.ts +3 -0
- package/dist/views/page-editor/theme-chrome/useThemeChromeMode.d.ts.map +1 -0
- package/dist/views/page-editor/theme-chrome/useThemeChromeMode.js +17 -0
- package/dist/views/page-editor/theme-chrome/useThemeChromeMode.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +14 -1
- package/src/__tests__/assets/prenta-logo.test.tsx +26 -0
- package/src/__tests__/layout/sidebar-brand.render.test.tsx +15 -0
- package/src/__tests__/lib/theme-chrome.test.ts +132 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +13 -0
- package/src/__tests__/views/login-branding.render.test.tsx +2 -1
- package/src/__tests__/views/theme-chrome-frame.render.test.tsx +304 -0
- package/src/assets/prenta-logo.tsx +47 -21
- package/src/index.ts +17 -0
- package/src/layout/Sidebar.tsx +2 -27
- package/src/lib/theme-chrome.ts +172 -0
- package/src/views/Login.tsx +5 -4
- package/src/views/page-editor/CanvasSurface.tsx +70 -24
- package/src/views/page-editor/EditorCanvas.tsx +89 -15
- package/src/views/page-editor/EditorTopBar.tsx +10 -0
- package/src/views/page-editor/PageSectionEditor.tsx +42 -0
- package/src/views/page-editor/theme-chrome/ThemeChromeFrame.tsx +119 -0
- package/src/views/page-editor/theme-chrome/ThemeChromeHealth.tsx +66 -0
- package/src/views/page-editor/theme-chrome/ThemeChromeModeToggle.tsx +63 -0
- package/src/views/page-editor/theme-chrome/ThemeChromeProvider.tsx +43 -0
- package/src/views/page-editor/theme-chrome/ThemeChromeSlot.tsx +235 -0
- package/src/views/page-editor/theme-chrome/index.ts +12 -0
- package/src/views/page-editor/theme-chrome/useThemeChromeMode.ts +24 -0
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -34,34 +34,9 @@ import {
|
|
|
34
34
|
Trash2,
|
|
35
35
|
} from 'lucide-react'
|
|
36
36
|
import type { LucideIcon } from 'lucide-react'
|
|
37
|
-
import { PrentaBrandLogo } from '../assets/prenta-logo.js'
|
|
37
|
+
import { PrentaBrandLogo, PrentaMark } from '../assets/prenta-logo.js'
|
|
38
38
|
import { LogoPlaceholder } from '../components/LogoPlaceholder.js'
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
* Compact mark used in the collapsed sidebar — just the "C" symbol from the
|
|
42
|
-
* full lockup, drawn as its own simple SVG so it stays crisp at 32×32.
|
|
43
|
-
*/
|
|
44
|
-
function PrentaMark({ className }: { className?: string }) {
|
|
45
|
-
return (
|
|
46
|
-
<svg
|
|
47
|
-
viewBox="0 0 40 44"
|
|
48
|
-
fill="none"
|
|
49
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
50
|
-
className={className}
|
|
51
|
-
aria-hidden="true"
|
|
52
|
-
>
|
|
53
|
-
<polygon points="20,2 6,18 12,18 20,8 28,18 34,18" fill="#F05E65" />
|
|
54
|
-
<rect x="11" y="20" width="4" height="22" rx="1" fill="#F05E65" />
|
|
55
|
-
<rect x="18" y="20" width="4" height="22" rx="1" fill="#F05E65" />
|
|
56
|
-
<rect x="25" y="20" width="4" height="22" rx="1" fill="#F05E65" />
|
|
57
|
-
</svg>
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function PrentaWordmark({ className }: { className?: string }) {
|
|
62
|
-
return <PrentaBrandLogo className={className} />
|
|
63
|
-
}
|
|
64
|
-
|
|
65
40
|
const ICON_MAP: Record<string, LucideIcon> = {
|
|
66
41
|
file: File,
|
|
67
42
|
'file-text': FileText,
|
|
@@ -180,7 +155,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
|
|
|
180
155
|
)
|
|
181
156
|
}
|
|
182
157
|
|
|
183
|
-
return <
|
|
158
|
+
return <PrentaBrandLogo className="h-8 w-auto" />
|
|
184
159
|
}
|
|
185
160
|
|
|
186
161
|
/**
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme chrome — site header/footer in the page editor canvas.
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers and the public contract. React lives in
|
|
5
|
+
* `views/page-editor/theme-chrome/`. Do not put renderers on
|
|
6
|
+
* `prenta.config.ts` — this file is imported from the admin bundle only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ReactNode } from 'react'
|
|
10
|
+
import type { BreakpointId } from './breakpoints.js'
|
|
11
|
+
|
|
12
|
+
export type ThemeChromeMode = 'flow' | 'frame'
|
|
13
|
+
export type ThemeChromeSurface = 'pages' | 'posts'
|
|
14
|
+
export type ThemeChromeEditTarget = 'navigations' | 'appearance' | 'settings'
|
|
15
|
+
export type ThemeChromeSlotStatus = 'ok' | 'preview' | 'fallback' | 'placeholder'
|
|
16
|
+
|
|
17
|
+
export interface ThemeChromeDocument {
|
|
18
|
+
id: string | null
|
|
19
|
+
title: string
|
|
20
|
+
path: string
|
|
21
|
+
collection: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AdminThemeChromeProps {
|
|
25
|
+
region: 'header' | 'footer'
|
|
26
|
+
breakpoint: BreakpointId
|
|
27
|
+
mode: ThemeChromeMode
|
|
28
|
+
preview: true
|
|
29
|
+
locale: string
|
|
30
|
+
dir: 'ltr' | 'rtl'
|
|
31
|
+
document: ThemeChromeDocument
|
|
32
|
+
onEditChrome: (target: ThemeChromeEditTarget) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type AdminThemeChromeRenderer = (props: AdminThemeChromeProps) => ReactNode
|
|
36
|
+
|
|
37
|
+
export interface AdminThemeChrome {
|
|
38
|
+
header?: AdminThemeChromeRenderer
|
|
39
|
+
footer?: AdminThemeChromeRenderer
|
|
40
|
+
/** Shown when header/footer throw or are omitted but chrome is enabled. */
|
|
41
|
+
fallback?: AdminThemeChromeRenderer
|
|
42
|
+
/**
|
|
43
|
+
* Same-origin URL for hosts whose real chrome is server-only.
|
|
44
|
+
* Rendered in a sandboxed iframe inside the chrome slot; never the whole page.
|
|
45
|
+
*/
|
|
46
|
+
previewSrc?: { header?: string; footer?: string }
|
|
47
|
+
/** Surfaces that mount chrome. Default: pages only. */
|
|
48
|
+
surfaces?: ThemeChromeSurface[]
|
|
49
|
+
/** Default canvas mode. Default: flow (real document scroll). */
|
|
50
|
+
defaultMode?: ThemeChromeMode
|
|
51
|
+
/**
|
|
52
|
+
* Optional health-strip extras. Hosts that already know nav/brand state
|
|
53
|
+
* can pass them; the canvas will not fetch on their behalf.
|
|
54
|
+
*/
|
|
55
|
+
diagnostics?: { navLinkCount?: number; brandPresent?: boolean }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const THEME_CHROME_MODE_KEY = 'prenta-theme-chrome-mode'
|
|
59
|
+
export const THEME_CHROME_MAIN_ID = 'prenta-canvas-main'
|
|
60
|
+
|
|
61
|
+
export function isThemeChromeEnabled(
|
|
62
|
+
spec: AdminThemeChrome | null | undefined,
|
|
63
|
+
killSwitch: boolean | { pages?: boolean; posts?: boolean } | undefined,
|
|
64
|
+
surface: ThemeChromeSurface,
|
|
65
|
+
): boolean {
|
|
66
|
+
if (!spec) return false
|
|
67
|
+
const surfaces = spec.surfaces ?? ['pages']
|
|
68
|
+
if (!surfaces.includes(surface)) return false
|
|
69
|
+
if (killSwitch === false) return false
|
|
70
|
+
if (killSwitch && typeof killSwitch === 'object' && killSwitch[surface] === false) {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function readStoredChromeMode(): ThemeChromeMode | null {
|
|
77
|
+
if (typeof window === 'undefined') return null
|
|
78
|
+
try {
|
|
79
|
+
const value = window.localStorage.getItem(THEME_CHROME_MODE_KEY)
|
|
80
|
+
return value === 'flow' || value === 'frame' ? value : null
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function writeStoredChromeMode(mode: ThemeChromeMode): void {
|
|
87
|
+
if (typeof window === 'undefined') return
|
|
88
|
+
try {
|
|
89
|
+
window.localStorage.setItem(THEME_CHROME_MODE_KEY, mode)
|
|
90
|
+
} catch {
|
|
91
|
+
// Private mode / quota — mode still works for this session.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function resolveThemeChromeLocale(opts: {
|
|
96
|
+
documentLocale?: string | null
|
|
97
|
+
activeLocale?: string | null
|
|
98
|
+
defaultLocale?: string | null
|
|
99
|
+
}): string {
|
|
100
|
+
const picked = opts.documentLocale || opts.activeLocale || opts.defaultLocale || 'en'
|
|
101
|
+
return picked.trim() || 'en'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveThemeChromeDir(
|
|
105
|
+
locale: string,
|
|
106
|
+
locales: Array<{ code: string; dir?: 'ltr' | 'rtl' }>,
|
|
107
|
+
): 'ltr' | 'rtl' {
|
|
108
|
+
return locales.find((entry) => entry.code === locale)?.dir === 'rtl' ? 'rtl' : 'ltr'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Public path for chrome context. Serving-path first; never an admin route.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveThemeChromePath(path: string | null | undefined, slug?: string): string {
|
|
115
|
+
const trimmed = (path ?? '').trim()
|
|
116
|
+
if (trimmed) return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
|
117
|
+
const s = (slug ?? '').trim().replace(/^\/+/, '')
|
|
118
|
+
if (!s || s === 'home' || s === 'index') return '/'
|
|
119
|
+
return `/${s}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function themeChromeEditPath(target: ThemeChromeEditTarget): string {
|
|
123
|
+
switch (target) {
|
|
124
|
+
case 'navigations':
|
|
125
|
+
return '/navigations'
|
|
126
|
+
case 'appearance':
|
|
127
|
+
return '/settings?tab=appearance'
|
|
128
|
+
case 'settings':
|
|
129
|
+
return '/settings?tab=general'
|
|
130
|
+
default: {
|
|
131
|
+
const _exhaustive: never = target
|
|
132
|
+
return _exhaustive
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Same-origin chrome-only preview URLs. Relative paths starting with `/`
|
|
139
|
+
* (not `//`) are allowed. Absolute URLs must match `origin`. Protocol-relative,
|
|
140
|
+
* data:, javascript:, and off-origin URLs are rejected.
|
|
141
|
+
*/
|
|
142
|
+
export function isSameOriginPreviewSrc(src: string, origin?: string): boolean {
|
|
143
|
+
const trimmed = src.trim()
|
|
144
|
+
if (!trimmed) return false
|
|
145
|
+
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
|
|
146
|
+
return !trimmed.toLowerCase().startsWith('/\\') && !/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
|
|
147
|
+
}
|
|
148
|
+
let parsed: URL
|
|
149
|
+
try {
|
|
150
|
+
parsed = new URL(trimmed)
|
|
151
|
+
} catch {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
|
155
|
+
const expected = origin ?? (typeof window !== 'undefined' ? window.location.origin : '')
|
|
156
|
+
if (!expected) return false
|
|
157
|
+
try {
|
|
158
|
+
return parsed.origin === new URL(expected).origin
|
|
159
|
+
} catch {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function chromeContainsH1(root: ParentNode | null): boolean {
|
|
165
|
+
return Boolean(root?.querySelector('h1'))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function closestAnchor(target: EventTarget | null): HTMLAnchorElement | null {
|
|
169
|
+
if (!(target instanceof Element)) return null
|
|
170
|
+
const found = target.closest('a[href]')
|
|
171
|
+
return found instanceof HTMLAnchorElement ? found : null
|
|
172
|
+
}
|
package/src/views/Login.tsx
CHANGED
|
@@ -53,7 +53,7 @@ export interface LoginProps {
|
|
|
53
53
|
captchaConfig?: CaptchaConfig
|
|
54
54
|
/**
|
|
55
55
|
* Custom branding for the login screen. Falls back to the bundled Prenta
|
|
56
|
-
*
|
|
56
|
+
* wordmark when omitted.
|
|
57
57
|
*/
|
|
58
58
|
branding?: LoginBrandingConfig
|
|
59
59
|
}
|
|
@@ -377,9 +377,10 @@ export function Login({
|
|
|
377
377
|
const hasInitials = (value: string | null | undefined) => Boolean(value) && value !== 'A'
|
|
378
378
|
const isBranded =
|
|
379
379
|
Boolean(branding?.name) || hasInitials(adminBrand.initials) || hasInitials(publicInitials)
|
|
380
|
-
//
|
|
381
|
-
// headline
|
|
382
|
-
|
|
380
|
+
// A logo image or the bundled wordmark already carries the brand — a
|
|
381
|
+
// separate headline would duplicate it. Keep the name only for the
|
|
382
|
+
// branded-but-logo-less placeholder.
|
|
383
|
+
const showBrandName = !effectiveLogo && isBranded
|
|
383
384
|
|
|
384
385
|
return (
|
|
385
386
|
<div className="bg-muted flex min-h-screen items-center justify-center px-4">
|
|
@@ -21,6 +21,17 @@ export interface CanvasSurfaceProps {
|
|
|
21
21
|
onCanvasElement?: (el: HTMLElement | null) => void
|
|
22
22
|
/** Tips, notices, or the review bar — rendered above the meta line. */
|
|
23
23
|
chrome?: React.ReactNode
|
|
24
|
+
/** Theme-chrome health strip — locale, slot status, outline warning. */
|
|
25
|
+
health?: React.ReactNode
|
|
26
|
+
/** `lang` on `.prenta-canvas` when theme chrome is mounted. */
|
|
27
|
+
lang?: string
|
|
28
|
+
/** `dir` on `.prenta-canvas` when theme chrome is mounted. */
|
|
29
|
+
dir?: 'ltr' | 'rtl'
|
|
30
|
+
/**
|
|
31
|
+
* `frame` fills the remaining pane and expects children to scroll internally.
|
|
32
|
+
* `flow` (default) sizes the canvas from content height, as today.
|
|
33
|
+
*/
|
|
34
|
+
fillMode?: 'flow' | 'frame'
|
|
24
35
|
/** The page itself. Rendered inside the scaled, container-queried surface. */
|
|
25
36
|
children: React.ReactNode
|
|
26
37
|
}
|
|
@@ -57,14 +68,22 @@ export function CanvasSurface({
|
|
|
57
68
|
onScaleChange,
|
|
58
69
|
onCanvasElement,
|
|
59
70
|
chrome,
|
|
71
|
+
health,
|
|
72
|
+
lang,
|
|
73
|
+
dir,
|
|
74
|
+
fillMode = 'flow',
|
|
60
75
|
children,
|
|
61
76
|
}: CanvasSurfaceProps) {
|
|
62
77
|
const bp = getBreakpoint(breakpoint)
|
|
63
78
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
64
79
|
const surfaceRef = useRef<HTMLDivElement>(null)
|
|
80
|
+
const areaRef = useRef<HTMLDivElement>(null)
|
|
65
81
|
|
|
66
82
|
const [available, setAvailable] = useState(0)
|
|
67
83
|
const [pageHeight, setPageHeight] = useState(0)
|
|
84
|
+
const [areaHeight, setAreaHeight] = useState(0)
|
|
85
|
+
|
|
86
|
+
const isFrame = fillMode === 'frame'
|
|
68
87
|
|
|
69
88
|
// No dependency array: page height changes on edits that resize nothing the
|
|
70
89
|
// observer watches. Both setters bail when the value is unchanged, so this
|
|
@@ -76,6 +95,9 @@ export function CanvasSurface({
|
|
|
76
95
|
|
|
77
96
|
const height = surfaceRef.current?.offsetHeight ?? 0
|
|
78
97
|
if (height > 0) setPageHeight((prev) => (prev === height ? prev : height))
|
|
98
|
+
|
|
99
|
+
const area = areaRef.current?.clientHeight ?? 0
|
|
100
|
+
if (area > 0) setAreaHeight((prev) => (prev === area ? prev : area))
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
measure()
|
|
@@ -83,10 +105,12 @@ export function CanvasSurface({
|
|
|
83
105
|
const ro = new ResizeObserver(measure)
|
|
84
106
|
if (sentinelRef.current) ro.observe(sentinelRef.current)
|
|
85
107
|
if (surfaceRef.current) ro.observe(surfaceRef.current)
|
|
108
|
+
if (areaRef.current) ro.observe(areaRef.current)
|
|
86
109
|
return () => ro.disconnect()
|
|
87
110
|
})
|
|
88
111
|
|
|
89
112
|
const scale = fitScale(available, bp.width, zoom)
|
|
113
|
+
const frameUnscaledHeight = isFrame && scale > 0 && areaHeight > 0 ? areaHeight / scale : 0
|
|
90
114
|
|
|
91
115
|
useEffect(() => {
|
|
92
116
|
onScaleChange?.(scale)
|
|
@@ -104,6 +128,7 @@ export function CanvasSurface({
|
|
|
104
128
|
|
|
105
129
|
{chrome}
|
|
106
130
|
<CanvasMetaLine breakpoint={bp} pageHeight={pageHeight} scale={scale} />
|
|
131
|
+
{health}
|
|
107
132
|
|
|
108
133
|
{/*
|
|
109
134
|
* Fit box: reserves the scaled footprint so the surrounding scroll area
|
|
@@ -112,32 +137,41 @@ export function CanvasSurface({
|
|
|
112
137
|
* that is not visible never finishes one) reports a bogus height and
|
|
113
138
|
* corrupts the next measurement.
|
|
114
139
|
*/}
|
|
115
|
-
<div
|
|
116
|
-
className="prenta-canvas-fit mx-auto block"
|
|
117
|
-
style={{
|
|
118
|
-
width: Math.round(bp.width * scale),
|
|
119
|
-
height: pageHeight ? Math.round(pageHeight * scale) : undefined,
|
|
120
|
-
}}
|
|
121
|
-
>
|
|
122
|
-
{/* `prenta-canvas` is the public hook for consumer brand CSS: site
|
|
123
|
-
styles that should render in this preview scope to
|
|
124
|
-
`.prenta-admin .prenta-canvas` instead of the admin root. It also
|
|
125
|
-
carries `container-type: inline-size` (theme.css) — that is what
|
|
126
|
-
makes the `@pv-*` steps in the section renderers resolve against
|
|
127
|
-
the simulated width instead of the viewport. */}
|
|
140
|
+
<div ref={areaRef} className={isFrame ? 'relative min-h-0 flex-1' : undefined}>
|
|
128
141
|
<div
|
|
129
|
-
|
|
130
|
-
surfaceRef.current = el
|
|
131
|
-
onCanvasElement?.(el)
|
|
132
|
-
}}
|
|
133
|
-
className="prenta-canvas border-border bg-card origin-top-left overflow-hidden rounded-lg text-left shadow-[0_2px_20px_rgba(0,0,0,0.12)] motion-safe:transition-[width,transform] motion-safe:duration-(--mo-slow) motion-safe:ease-(--ease)"
|
|
142
|
+
className="prenta-canvas-fit mx-auto block"
|
|
134
143
|
style={{
|
|
135
|
-
width: bp.width,
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
width: Math.round(bp.width * scale),
|
|
145
|
+
height: isFrame
|
|
146
|
+
? areaHeight || undefined
|
|
147
|
+
: pageHeight
|
|
148
|
+
? Math.round(pageHeight * scale)
|
|
149
|
+
: undefined,
|
|
138
150
|
}}
|
|
139
151
|
>
|
|
140
|
-
{
|
|
152
|
+
{/* `prenta-canvas` is the public hook for consumer brand CSS: site
|
|
153
|
+
styles that should render in this preview scope to
|
|
154
|
+
`.prenta-admin .prenta-canvas` instead of the admin root. It also
|
|
155
|
+
carries `container-type: inline-size` (theme.css) — that is what
|
|
156
|
+
makes the `@pv-*` steps in the section renderers resolve against
|
|
157
|
+
the simulated width instead of the viewport. */}
|
|
158
|
+
<div
|
|
159
|
+
ref={(el) => {
|
|
160
|
+
surfaceRef.current = el
|
|
161
|
+
onCanvasElement?.(el)
|
|
162
|
+
}}
|
|
163
|
+
lang={lang}
|
|
164
|
+
dir={dir}
|
|
165
|
+
className="prenta-canvas border-border bg-card origin-top-left overflow-hidden rounded-lg text-left shadow-[0_2px_20px_rgba(0,0,0,0.12)] motion-safe:transition-[width,transform] motion-safe:duration-(--mo-slow) motion-safe:ease-(--ease)"
|
|
166
|
+
style={{
|
|
167
|
+
width: bp.width,
|
|
168
|
+
height: isFrame && frameUnscaledHeight ? frameUnscaledHeight : undefined,
|
|
169
|
+
transform: `scale(${scale})`,
|
|
170
|
+
transformOrigin: 'top left',
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{children}
|
|
174
|
+
</div>
|
|
141
175
|
</div>
|
|
142
176
|
</div>
|
|
143
177
|
</>
|
|
@@ -145,9 +179,21 @@ export function CanvasSurface({
|
|
|
145
179
|
}
|
|
146
180
|
|
|
147
181
|
/** The scroll container both editors wrap their surface in. */
|
|
148
|
-
export function CanvasScroll({
|
|
182
|
+
export function CanvasScroll({
|
|
183
|
+
children,
|
|
184
|
+
layout = 'flow',
|
|
185
|
+
}: {
|
|
186
|
+
children: React.ReactNode
|
|
187
|
+
layout?: 'flow' | 'frame'
|
|
188
|
+
}) {
|
|
149
189
|
return (
|
|
150
|
-
<div
|
|
190
|
+
<div
|
|
191
|
+
className={
|
|
192
|
+
layout === 'frame'
|
|
193
|
+
? 'prenta-canvas-scroll bg-muted flex h-full min-h-0 flex-col overflow-hidden px-6 pt-4.5 pb-10 text-center'
|
|
194
|
+
: 'prenta-canvas-scroll bg-muted h-full overflow-auto px-6 pt-4.5 pb-10 text-center'
|
|
195
|
+
}
|
|
196
|
+
>
|
|
151
197
|
{children}
|
|
152
198
|
</div>
|
|
153
199
|
)
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
3
4
|
import { LayoutTemplate } from 'lucide-react'
|
|
4
5
|
import { CanvasEditTip } from '../../components/CanvasEditTip.js'
|
|
5
6
|
import { CanvasScroll, CanvasSurface, useSectionRefs } from './CanvasSurface.js'
|
|
6
7
|
import type { BreakpointId, Zoom } from '../../lib/breakpoints.js'
|
|
7
8
|
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
9
|
+
import {
|
|
10
|
+
themeChromeEditPath,
|
|
11
|
+
type AdminThemeChrome,
|
|
12
|
+
type ThemeChromeDocument,
|
|
13
|
+
type ThemeChromeEditTarget,
|
|
14
|
+
type ThemeChromeMode,
|
|
15
|
+
} from '../../lib/theme-chrome.js'
|
|
8
16
|
import { SectionRenderer } from './sections/index.js'
|
|
9
17
|
import { StructureNotice } from './StructureNotice.js'
|
|
18
|
+
import { ThemeChromeFrame, ThemeChromeHealth } from './theme-chrome/index.js'
|
|
19
|
+
import type { ThemeChromeHealthState } from './theme-chrome/ThemeChromeFrame.js'
|
|
10
20
|
|
|
11
21
|
interface EditorCanvasProps {
|
|
12
22
|
sections: PageSection[]
|
|
@@ -31,6 +41,16 @@ interface EditorCanvasProps {
|
|
|
31
41
|
* to the editor's history state, which the canvas knows nothing about.
|
|
32
42
|
*/
|
|
33
43
|
reviewBar?: React.ReactNode
|
|
44
|
+
/** When set and enabled, wraps bands in site header/footer. */
|
|
45
|
+
themeChrome?: {
|
|
46
|
+
spec: AdminThemeChrome
|
|
47
|
+
enabled: boolean
|
|
48
|
+
mode: ThemeChromeMode
|
|
49
|
+
locale: string
|
|
50
|
+
dir: 'ltr' | 'rtl'
|
|
51
|
+
document: ThemeChromeDocument
|
|
52
|
+
onNavigate: (path: string) => void
|
|
53
|
+
}
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
/**
|
|
@@ -52,13 +72,44 @@ export function EditorCanvas({
|
|
|
52
72
|
reviewing = false,
|
|
53
73
|
changedSectionIds,
|
|
54
74
|
reviewBar,
|
|
75
|
+
themeChrome,
|
|
55
76
|
}: EditorCanvasProps) {
|
|
56
77
|
const registerRef = useSectionRefs(selectedId)
|
|
78
|
+
const [health, setHealth] = useState<ThemeChromeHealthState | null>(null)
|
|
79
|
+
|
|
80
|
+
const chromeOn = Boolean(themeChrome?.enabled)
|
|
81
|
+
const mode = themeChrome?.mode ?? 'flow'
|
|
82
|
+
|
|
83
|
+
const onEditChrome = useCallback(
|
|
84
|
+
(target: ThemeChromeEditTarget) => {
|
|
85
|
+
themeChrome?.onNavigate(themeChromeEditPath(target))
|
|
86
|
+
},
|
|
87
|
+
[themeChrome],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const documentIdentity = themeChrome?.document
|
|
91
|
+
|
|
92
|
+
const bands = useMemo(() => {
|
|
93
|
+
if (sections.length === 0) return <EmptyCanvas />
|
|
94
|
+
return sections.map((section) => (
|
|
95
|
+
<SectionRenderer
|
|
96
|
+
key={section.id}
|
|
97
|
+
section={section}
|
|
98
|
+
editable
|
|
99
|
+
reviewing={reviewing}
|
|
100
|
+
changed={changedSectionIds?.has(section.id) ?? false}
|
|
101
|
+
selected={section.id === selectedId}
|
|
102
|
+
onSelect={onSelect}
|
|
103
|
+
registerRef={registerRef}
|
|
104
|
+
/>
|
|
105
|
+
))
|
|
106
|
+
}, [sections, reviewing, changedSectionIds, selectedId, onSelect, registerRef])
|
|
57
107
|
|
|
58
108
|
// A design-owned page always ships the template's sections, so an empty
|
|
59
109
|
// canvas means the template resolved to nothing — a configuration problem,
|
|
60
|
-
// not a page to scale and measure.
|
|
61
|
-
|
|
110
|
+
// not a page to scale and measure. Theme chrome still mounts so editors
|
|
111
|
+
// can see and edit the site shell.
|
|
112
|
+
if (sections.length === 0 && !chromeOn) {
|
|
62
113
|
return (
|
|
63
114
|
<CanvasScroll>
|
|
64
115
|
<EmptyCanvas />
|
|
@@ -66,13 +117,47 @@ export function EditorCanvas({
|
|
|
66
117
|
)
|
|
67
118
|
}
|
|
68
119
|
|
|
120
|
+
const inner =
|
|
121
|
+
chromeOn && themeChrome && documentIdentity ? (
|
|
122
|
+
<ThemeChromeFrame
|
|
123
|
+
spec={themeChrome.spec}
|
|
124
|
+
mode={mode}
|
|
125
|
+
breakpoint={breakpoint}
|
|
126
|
+
locale={themeChrome.locale}
|
|
127
|
+
dir={themeChrome.dir}
|
|
128
|
+
document={documentIdentity}
|
|
129
|
+
onEditChrome={onEditChrome}
|
|
130
|
+
onHealthChange={setHealth}
|
|
131
|
+
>
|
|
132
|
+
{bands}
|
|
133
|
+
</ThemeChromeFrame>
|
|
134
|
+
) : (
|
|
135
|
+
bands
|
|
136
|
+
)
|
|
137
|
+
|
|
69
138
|
return (
|
|
70
|
-
<CanvasScroll>
|
|
139
|
+
<CanvasScroll layout={chromeOn && mode === 'frame' ? 'frame' : 'flow'}>
|
|
71
140
|
<CanvasSurface
|
|
72
141
|
breakpoint={breakpoint}
|
|
73
142
|
zoom={zoom}
|
|
74
143
|
onScaleChange={onScaleChange}
|
|
75
144
|
onCanvasElement={onCanvasElement}
|
|
145
|
+
lang={chromeOn ? themeChrome?.locale : undefined}
|
|
146
|
+
dir={chromeOn ? themeChrome?.dir : undefined}
|
|
147
|
+
fillMode={chromeOn && mode === 'frame' ? 'frame' : 'flow'}
|
|
148
|
+
health={
|
|
149
|
+
chromeOn && themeChrome && health ? (
|
|
150
|
+
<ThemeChromeHealth
|
|
151
|
+
locale={themeChrome.locale}
|
|
152
|
+
dir={themeChrome.dir}
|
|
153
|
+
header={health.header}
|
|
154
|
+
footer={health.footer}
|
|
155
|
+
outlineWarning={health.outlineWarning}
|
|
156
|
+
navLinkCount={themeChrome.spec.diagnostics?.navLinkCount}
|
|
157
|
+
brandPresent={themeChrome.spec.diagnostics?.brandPresent}
|
|
158
|
+
/>
|
|
159
|
+
) : undefined
|
|
160
|
+
}
|
|
76
161
|
chrome={
|
|
77
162
|
// The review bar REPLACES the editing chrome rather than joining it:
|
|
78
163
|
// a tip about clicking sections to edit them is actively wrong while
|
|
@@ -87,18 +172,7 @@ export function EditorCanvas({
|
|
|
87
172
|
)
|
|
88
173
|
}
|
|
89
174
|
>
|
|
90
|
-
{
|
|
91
|
-
<SectionRenderer
|
|
92
|
-
key={section.id}
|
|
93
|
-
section={section}
|
|
94
|
-
editable
|
|
95
|
-
reviewing={reviewing}
|
|
96
|
-
changed={changedSectionIds?.has(section.id) ?? false}
|
|
97
|
-
selected={section.id === selectedId}
|
|
98
|
-
onSelect={onSelect}
|
|
99
|
-
registerRef={registerRef}
|
|
100
|
-
/>
|
|
101
|
-
))}
|
|
175
|
+
{inner}
|
|
102
176
|
</CanvasSurface>
|
|
103
177
|
</CanvasScroll>
|
|
104
178
|
)
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
type WorkflowStage,
|
|
31
31
|
} from '../../lib/workflow-chrome.js'
|
|
32
32
|
import { type ViewportId } from './viewports.js'
|
|
33
|
+
import type { ThemeChromeMode } from '../../lib/theme-chrome.js'
|
|
34
|
+
import { ThemeChromeModeToggle } from './theme-chrome/ThemeChromeModeToggle.js'
|
|
33
35
|
|
|
34
36
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
|
|
35
37
|
|
|
@@ -56,6 +58,9 @@ interface EditorTopBarProps {
|
|
|
56
58
|
/** Breadcrumb root label + back-button text (e.g. "Pages" or "Posts"). */
|
|
57
59
|
backLabel?: string
|
|
58
60
|
onViewport: (v: ViewportId) => void
|
|
61
|
+
/** Theme-chrome Page / Frame toggle. Hidden when theme chrome is off. */
|
|
62
|
+
chromeMode?: ThemeChromeMode
|
|
63
|
+
onChromeMode?: (mode: ThemeChromeMode) => void
|
|
59
64
|
onBack: () => void
|
|
60
65
|
/**
|
|
61
66
|
* Open the public site in a new tab. Called with `preview-draft` or
|
|
@@ -129,6 +134,8 @@ export function EditorTopBar({
|
|
|
129
134
|
onZoom,
|
|
130
135
|
backLabel = 'Pages',
|
|
131
136
|
onViewport,
|
|
137
|
+
chromeMode,
|
|
138
|
+
onChromeMode,
|
|
132
139
|
onBack,
|
|
133
140
|
onViewOnSite,
|
|
134
141
|
onShare,
|
|
@@ -281,6 +288,9 @@ export function EditorTopBar({
|
|
|
281
288
|
{zoom !== undefined && onZoom && (
|
|
282
289
|
<ZoomChip zoom={zoom} scale={canvasScale ?? 1} onChange={onZoom} />
|
|
283
290
|
)}
|
|
291
|
+
{chromeMode && onChromeMode ? (
|
|
292
|
+
<ThemeChromeModeToggle mode={chromeMode} onChange={onChromeMode} />
|
|
293
|
+
) : null}
|
|
284
294
|
</div>
|
|
285
295
|
|
|
286
296
|
<button
|
|
@@ -55,6 +55,15 @@ import { hasCanvasRenderer, useAdminSectionRenderers } from './sections/index.js
|
|
|
55
55
|
import { EditorTopBar, type SaveState } from './EditorTopBar.js'
|
|
56
56
|
import { SectionsList } from './SectionsPanel.js'
|
|
57
57
|
import { EditorCanvas } from './EditorCanvas.js'
|
|
58
|
+
import { useLocale } from '../../components/LocaleProvider.js'
|
|
59
|
+
import {
|
|
60
|
+
isThemeChromeEnabled,
|
|
61
|
+
resolveThemeChromeDir,
|
|
62
|
+
resolveThemeChromeLocale,
|
|
63
|
+
resolveThemeChromePath,
|
|
64
|
+
} from '../../lib/theme-chrome.js'
|
|
65
|
+
import { useThemeChromeSpec } from './theme-chrome/ThemeChromeProvider.js'
|
|
66
|
+
import { useThemeChromeMode } from './theme-chrome/useThemeChromeMode.js'
|
|
58
67
|
import { ReflowContext } from './reflow-context.js'
|
|
59
68
|
import { SectionEditForm } from './SectionEditForm.js'
|
|
60
69
|
import { PageSettingsModal } from './PageSettingsModal.js'
|
|
@@ -135,6 +144,19 @@ export function PageSectionEditor({
|
|
|
135
144
|
const [pendingFocusPath, setPendingFocusPath] = useState<string | null>(null)
|
|
136
145
|
const [viewport, setViewport] = useState<ViewportId>(DEFAULT_VIEWPORT)
|
|
137
146
|
const [zoom, setZoom] = useState<Zoom>('fit')
|
|
147
|
+
const themeChromeSpec = useThemeChromeSpec()
|
|
148
|
+
const themeChromeEnabled = isThemeChromeEnabled(
|
|
149
|
+
themeChromeSpec,
|
|
150
|
+
config?.admin?.editor?.themeChrome,
|
|
151
|
+
'pages',
|
|
152
|
+
)
|
|
153
|
+
const [chromeMode, setChromeMode] = useThemeChromeMode(themeChromeSpec?.defaultMode ?? 'flow')
|
|
154
|
+
const { activeLocale, locales } = useLocale()
|
|
155
|
+
const chromeLocale = resolveThemeChromeLocale({
|
|
156
|
+
activeLocale,
|
|
157
|
+
defaultLocale: config?.i18n?.defaultLocale,
|
|
158
|
+
})
|
|
159
|
+
const chromeDir = resolveThemeChromeDir(chromeLocale, locales)
|
|
138
160
|
/**
|
|
139
161
|
* Live audit reported by the SEO pane, built from unsaved form state. Null
|
|
140
162
|
* until that pane has mounted — `audit` below falls back to the saved-record
|
|
@@ -881,6 +903,8 @@ export function PageSectionEditor({
|
|
|
881
903
|
zoom={zoom}
|
|
882
904
|
canvasScale={canvasScale}
|
|
883
905
|
onZoom={setZoom}
|
|
906
|
+
chromeMode={themeChromeEnabled ? chromeMode : undefined}
|
|
907
|
+
onChromeMode={themeChromeEnabled ? setChromeMode : undefined}
|
|
884
908
|
onBack={() => guardedNavigate('/pages')}
|
|
885
909
|
onViewOnSite={page.id && page.slug ? handleViewOnSite : undefined}
|
|
886
910
|
onShare={page.id ? handleShare : undefined}
|
|
@@ -1050,6 +1074,24 @@ export function PageSectionEditor({
|
|
|
1050
1074
|
structuralTypes={structuralTypes}
|
|
1051
1075
|
reviewing={reviewing}
|
|
1052
1076
|
changedSectionIds={reviewChangedIds}
|
|
1077
|
+
themeChrome={
|
|
1078
|
+
themeChromeSpec
|
|
1079
|
+
? {
|
|
1080
|
+
spec: themeChromeSpec,
|
|
1081
|
+
enabled: themeChromeEnabled,
|
|
1082
|
+
mode: chromeMode,
|
|
1083
|
+
locale: chromeLocale,
|
|
1084
|
+
dir: chromeDir,
|
|
1085
|
+
document: {
|
|
1086
|
+
id: page.id,
|
|
1087
|
+
title: page.title,
|
|
1088
|
+
path: resolveThemeChromePath(page.path, page.slug),
|
|
1089
|
+
collection: PAGES_COLLECTION,
|
|
1090
|
+
},
|
|
1091
|
+
onNavigate,
|
|
1092
|
+
}
|
|
1093
|
+
: undefined
|
|
1094
|
+
}
|
|
1053
1095
|
reviewBar={
|
|
1054
1096
|
reviewVersion ? (
|
|
1055
1097
|
<ReviewBar
|