@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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
THEME_CHROME_MAIN_ID,
|
|
6
|
+
type AdminThemeChrome,
|
|
7
|
+
type AdminThemeChromeProps,
|
|
8
|
+
type ThemeChromeDocument,
|
|
9
|
+
type ThemeChromeEditTarget,
|
|
10
|
+
type ThemeChromeMode,
|
|
11
|
+
type ThemeChromeSlotStatus,
|
|
12
|
+
} from '../../../lib/theme-chrome.js'
|
|
13
|
+
import type { BreakpointId } from '../../../lib/breakpoints.js'
|
|
14
|
+
import { ThemeChromeSlot } from './ThemeChromeSlot.js'
|
|
15
|
+
|
|
16
|
+
export interface ThemeChromeHealthState {
|
|
17
|
+
header: ThemeChromeSlotStatus
|
|
18
|
+
footer: ThemeChromeSlotStatus
|
|
19
|
+
outlineWarning: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ThemeChromeFrameProps {
|
|
23
|
+
spec: AdminThemeChrome
|
|
24
|
+
mode: ThemeChromeMode
|
|
25
|
+
breakpoint: BreakpointId
|
|
26
|
+
locale: string
|
|
27
|
+
dir: 'ltr' | 'rtl'
|
|
28
|
+
document: ThemeChromeDocument
|
|
29
|
+
onEditChrome: (target: ThemeChromeEditTarget) => void
|
|
30
|
+
onHealthChange?: (health: ThemeChromeHealthState) => void
|
|
31
|
+
children: ReactNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ThemeChromeFrame({
|
|
35
|
+
spec,
|
|
36
|
+
mode,
|
|
37
|
+
breakpoint,
|
|
38
|
+
locale,
|
|
39
|
+
dir,
|
|
40
|
+
document,
|
|
41
|
+
onEditChrome,
|
|
42
|
+
onHealthChange,
|
|
43
|
+
children,
|
|
44
|
+
}: ThemeChromeFrameProps) {
|
|
45
|
+
const [headerStatus, setHeaderStatus] = useState<ThemeChromeSlotStatus>('placeholder')
|
|
46
|
+
const [footerStatus, setFooterStatus] = useState<ThemeChromeSlotStatus>('placeholder')
|
|
47
|
+
const [headerH1, setHeaderH1] = useState(false)
|
|
48
|
+
const [footerH1, setFooterH1] = useState(false)
|
|
49
|
+
|
|
50
|
+
const chromeProps = useMemo<Omit<AdminThemeChromeProps, 'region'>>(
|
|
51
|
+
() => ({
|
|
52
|
+
breakpoint,
|
|
53
|
+
mode,
|
|
54
|
+
preview: true,
|
|
55
|
+
locale,
|
|
56
|
+
dir,
|
|
57
|
+
document,
|
|
58
|
+
onEditChrome,
|
|
59
|
+
}),
|
|
60
|
+
[breakpoint, mode, locale, dir, document, onEditChrome],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const onHeaderOutline = useCallback((hasH1: boolean) => setHeaderH1(hasH1), [])
|
|
64
|
+
const onFooterOutline = useCallback((hasH1: boolean) => setFooterH1(hasH1), [])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
onHealthChange?.({
|
|
68
|
+
header: headerStatus,
|
|
69
|
+
footer: footerStatus,
|
|
70
|
+
outlineWarning: headerH1 || footerH1,
|
|
71
|
+
})
|
|
72
|
+
}, [headerStatus, footerStatus, headerH1, footerH1, onHealthChange])
|
|
73
|
+
|
|
74
|
+
const headerProps: AdminThemeChromeProps = { ...chromeProps, region: 'header' }
|
|
75
|
+
const footerProps: AdminThemeChromeProps = { ...chromeProps, region: 'footer' }
|
|
76
|
+
|
|
77
|
+
const frameClass = mode === 'frame' ? 'flex h-full min-h-0 flex-col' : 'flex min-h-0 flex-col'
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
className={frameClass}
|
|
82
|
+
data-prenta-theme-chrome-mode={mode}
|
|
83
|
+
data-prenta-theme-chrome-locale={locale}
|
|
84
|
+
data-prenta-theme-chrome-dir={dir}
|
|
85
|
+
data-prenta-theme-chrome-path={document.path}
|
|
86
|
+
>
|
|
87
|
+
<a
|
|
88
|
+
href={`#${THEME_CHROME_MAIN_ID}`}
|
|
89
|
+
className="bg-primary text-primary-foreground sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-30 focus:rounded-md focus:px-3 focus:py-1.5 focus:text-sm"
|
|
90
|
+
>
|
|
91
|
+
Skip to content
|
|
92
|
+
</a>
|
|
93
|
+
<div className={mode === 'frame' ? 'shrink-0' : undefined}>
|
|
94
|
+
<ThemeChromeSlot
|
|
95
|
+
region="header"
|
|
96
|
+
spec={spec}
|
|
97
|
+
chromeProps={headerProps}
|
|
98
|
+
onOutlineIssue={onHeaderOutline}
|
|
99
|
+
onStatus={setHeaderStatus}
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
<div
|
|
103
|
+
id={THEME_CHROME_MAIN_ID}
|
|
104
|
+
className={mode === 'frame' ? 'min-h-0 flex-1 overflow-auto' : undefined}
|
|
105
|
+
>
|
|
106
|
+
{children}
|
|
107
|
+
</div>
|
|
108
|
+
<div className={mode === 'frame' ? 'shrink-0' : undefined}>
|
|
109
|
+
<ThemeChromeSlot
|
|
110
|
+
region="footer"
|
|
111
|
+
spec={spec}
|
|
112
|
+
chromeProps={footerProps}
|
|
113
|
+
onOutlineIssue={onFooterOutline}
|
|
114
|
+
onStatus={setFooterStatus}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ThemeChromeSlotStatus } from '../../../lib/theme-chrome.js'
|
|
4
|
+
|
|
5
|
+
export interface ThemeChromeHealthProps {
|
|
6
|
+
locale: string
|
|
7
|
+
dir: 'ltr' | 'rtl'
|
|
8
|
+
header: ThemeChromeSlotStatus
|
|
9
|
+
footer: ThemeChromeSlotStatus
|
|
10
|
+
outlineWarning: boolean
|
|
11
|
+
navLinkCount?: number
|
|
12
|
+
brandPresent?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STATUS_LABEL: Record<ThemeChromeSlotStatus, string> = {
|
|
16
|
+
ok: 'ok',
|
|
17
|
+
preview: 'preview',
|
|
18
|
+
fallback: 'fallback',
|
|
19
|
+
placeholder: 'View site',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ThemeChromeHealth({
|
|
23
|
+
locale,
|
|
24
|
+
dir,
|
|
25
|
+
header,
|
|
26
|
+
footer,
|
|
27
|
+
outlineWarning,
|
|
28
|
+
navLinkCount,
|
|
29
|
+
brandPresent,
|
|
30
|
+
}: ThemeChromeHealthProps) {
|
|
31
|
+
return (
|
|
32
|
+
<p
|
|
33
|
+
className="text-muted-foreground mb-3 flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1 text-[11px] font-medium tracking-[0.3px]"
|
|
34
|
+
data-prenta-theme-chrome-health=""
|
|
35
|
+
aria-live="polite"
|
|
36
|
+
>
|
|
37
|
+
<span className="text-foreground/70 uppercase">
|
|
38
|
+
{locale} · {dir.toUpperCase()}
|
|
39
|
+
</span>
|
|
40
|
+
<span aria-hidden>|</span>
|
|
41
|
+
<span>Header {STATUS_LABEL[header]}</span>
|
|
42
|
+
<span aria-hidden>|</span>
|
|
43
|
+
<span>Footer {STATUS_LABEL[footer]}</span>
|
|
44
|
+
{typeof navLinkCount === 'number' ? (
|
|
45
|
+
<>
|
|
46
|
+
<span aria-hidden>|</span>
|
|
47
|
+
<span>Nav: {navLinkCount} links</span>
|
|
48
|
+
</>
|
|
49
|
+
) : null}
|
|
50
|
+
{brandPresent === true ? (
|
|
51
|
+
<>
|
|
52
|
+
<span aria-hidden>|</span>
|
|
53
|
+
<span>Brand</span>
|
|
54
|
+
</>
|
|
55
|
+
) : null}
|
|
56
|
+
{outlineWarning ? (
|
|
57
|
+
<>
|
|
58
|
+
<span aria-hidden>|</span>
|
|
59
|
+
<span className="text-destructive" data-prenta-theme-chrome-outline-warning="">
|
|
60
|
+
Chrome uses H1 — page outline will be wrong
|
|
61
|
+
</span>
|
|
62
|
+
</>
|
|
63
|
+
) : null}
|
|
64
|
+
</p>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ThemeChromeMode } from '../../../lib/theme-chrome.js'
|
|
4
|
+
|
|
5
|
+
export interface ThemeChromeModeToggleProps {
|
|
6
|
+
mode: ThemeChromeMode
|
|
7
|
+
onChange: (mode: ThemeChromeMode) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Page = real document scroll (sellable preview). Frame = pinned chrome
|
|
12
|
+
* for authoring. Frame is a working view, not the live page.
|
|
13
|
+
*/
|
|
14
|
+
export function ThemeChromeModeToggle({ mode, onChange }: ThemeChromeModeToggleProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
role="group"
|
|
18
|
+
aria-label="Canvas layout. Page scrolls like the live site. Frame pins the header and footer."
|
|
19
|
+
className="border-border bg-card inline-flex h-[34px] overflow-hidden rounded-lg border"
|
|
20
|
+
>
|
|
21
|
+
<ModeButton
|
|
22
|
+
label="Page"
|
|
23
|
+
pressed={mode === 'flow'}
|
|
24
|
+
title="Page — header, bands, and footer scroll together like the live site."
|
|
25
|
+
onClick={() => onChange('flow')}
|
|
26
|
+
/>
|
|
27
|
+
<ModeButton
|
|
28
|
+
label="Frame"
|
|
29
|
+
pressed={mode === 'frame'}
|
|
30
|
+
title="Frame — header and footer stay pinned while bands scroll. Authoring view, not the live page."
|
|
31
|
+
onClick={() => onChange('frame')}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ModeButton({
|
|
38
|
+
label,
|
|
39
|
+
pressed,
|
|
40
|
+
title,
|
|
41
|
+
onClick,
|
|
42
|
+
}: {
|
|
43
|
+
label: string
|
|
44
|
+
pressed: boolean
|
|
45
|
+
title: string
|
|
46
|
+
onClick: () => void
|
|
47
|
+
}) {
|
|
48
|
+
return (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
aria-pressed={pressed}
|
|
52
|
+
title={title}
|
|
53
|
+
onClick={onClick}
|
|
54
|
+
className={`h-full px-2.5 text-[12.5px] font-medium transition-colors ${
|
|
55
|
+
pressed
|
|
56
|
+
? 'bg-primary text-primary-foreground'
|
|
57
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
58
|
+
}`}
|
|
59
|
+
>
|
|
60
|
+
{label}
|
|
61
|
+
</button>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
4
|
+
import type { AdminThemeChrome, AdminThemeChromeProps } from '../../../lib/theme-chrome.js'
|
|
5
|
+
|
|
6
|
+
const ThemeChromeSpecContext = createContext<AdminThemeChrome | null>(null)
|
|
7
|
+
|
|
8
|
+
export function ThemeChromeProvider({
|
|
9
|
+
value,
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
value: AdminThemeChrome | null
|
|
13
|
+
children: ReactNode
|
|
14
|
+
}) {
|
|
15
|
+
return <ThemeChromeSpecContext.Provider value={value}>{children}</ThemeChromeSpecContext.Provider>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The `themeChrome` prop passed to `<AdminRoot>`, or null when omitted. */
|
|
19
|
+
export function useThemeChromeSpec(): AdminThemeChrome | null {
|
|
20
|
+
return useContext(ThemeChromeSpecContext)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ThemeChromeRenderContext = createContext<AdminThemeChromeProps | null>(null)
|
|
24
|
+
|
|
25
|
+
export function ThemeChromeRenderProvider({
|
|
26
|
+
value,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
value: AdminThemeChromeProps
|
|
30
|
+
children: ReactNode
|
|
31
|
+
}) {
|
|
32
|
+
return (
|
|
33
|
+
<ThemeChromeRenderContext.Provider value={value}>{children}</ThemeChromeRenderContext.Provider>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fields passed to a consumer `SiteHeader` / `SiteFooter` so they can
|
|
39
|
+
* highlight the current path and locale without prop-drilling.
|
|
40
|
+
*/
|
|
41
|
+
export function useThemeChromeContext(): AdminThemeChromeProps | null {
|
|
42
|
+
return useContext(ThemeChromeRenderContext)
|
|
43
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef, useState, type MouseEvent } from 'react'
|
|
4
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
5
|
+
import { Pencil } from 'lucide-react'
|
|
6
|
+
import { ErrorBoundary } from '../../../components/ErrorBoundary.js'
|
|
7
|
+
import { adminPortalContainer } from '../../../lib/portal-container.js'
|
|
8
|
+
import {
|
|
9
|
+
closestAnchor,
|
|
10
|
+
isSameOriginPreviewSrc,
|
|
11
|
+
type AdminThemeChrome,
|
|
12
|
+
type AdminThemeChromeProps,
|
|
13
|
+
type ThemeChromeSlotStatus,
|
|
14
|
+
} from '../../../lib/theme-chrome.js'
|
|
15
|
+
import { ThemeChromeRenderProvider } from './ThemeChromeProvider.js'
|
|
16
|
+
|
|
17
|
+
interface ThemeChromeSlotProps {
|
|
18
|
+
region: 'header' | 'footer'
|
|
19
|
+
spec: AdminThemeChrome
|
|
20
|
+
chromeProps: AdminThemeChromeProps
|
|
21
|
+
onOutlineIssue: (hasH1: boolean) => void
|
|
22
|
+
onStatus: (status: ThemeChromeSlotStatus) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ThemeChromeSlot({
|
|
26
|
+
region,
|
|
27
|
+
spec,
|
|
28
|
+
chromeProps,
|
|
29
|
+
onOutlineIssue,
|
|
30
|
+
onStatus,
|
|
31
|
+
}: ThemeChromeSlotProps) {
|
|
32
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
33
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
34
|
+
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
onOutlineIssue(Boolean(rootRef.current?.querySelector('h1')))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const openEditMenu = useCallback(() => {
|
|
40
|
+
setMenuOpen(true)
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const onClickCapture = useCallback(
|
|
44
|
+
(event: MouseEvent) => {
|
|
45
|
+
const target = event.target
|
|
46
|
+
if (target instanceof Element && target.closest('[data-prenta-theme-chrome-edit]')) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (closestAnchor(target)) {
|
|
50
|
+
event.preventDefault()
|
|
51
|
+
event.stopPropagation()
|
|
52
|
+
}
|
|
53
|
+
openEditMenu()
|
|
54
|
+
},
|
|
55
|
+
[openEditMenu],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const onAuxClickCapture = useCallback((event: MouseEvent) => {
|
|
59
|
+
if (closestAnchor(event.target)) {
|
|
60
|
+
event.preventDefault()
|
|
61
|
+
event.stopPropagation()
|
|
62
|
+
}
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
const label = region === 'header' ? 'Site header' : 'Site footer'
|
|
66
|
+
const renderer = region === 'header' ? spec.header : spec.footer
|
|
67
|
+
const previewSrc = region === 'header' ? spec.previewSrc?.header : spec.previewSrc?.footer
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<ThemeChromeRenderProvider value={chromeProps}>
|
|
71
|
+
<div
|
|
72
|
+
ref={rootRef}
|
|
73
|
+
data-prenta-theme-chrome={region}
|
|
74
|
+
aria-label={label}
|
|
75
|
+
className="relative"
|
|
76
|
+
onClickCapture={onClickCapture}
|
|
77
|
+
onAuxClickCapture={onAuxClickCapture}
|
|
78
|
+
>
|
|
79
|
+
<ErrorBoundary
|
|
80
|
+
fallback={
|
|
81
|
+
<ChromeFallback
|
|
82
|
+
region={region}
|
|
83
|
+
spec={spec}
|
|
84
|
+
chromeProps={chromeProps}
|
|
85
|
+
previewSrc={previewSrc}
|
|
86
|
+
onStatus={onStatus}
|
|
87
|
+
/>
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
<ChromeBody
|
|
91
|
+
region={region}
|
|
92
|
+
renderer={renderer}
|
|
93
|
+
fallback={spec.fallback}
|
|
94
|
+
previewSrc={previewSrc}
|
|
95
|
+
chromeProps={chromeProps}
|
|
96
|
+
onStatus={onStatus}
|
|
97
|
+
/>
|
|
98
|
+
</ErrorBoundary>
|
|
99
|
+
|
|
100
|
+
<DropdownMenu.Root open={menuOpen} onOpenChange={setMenuOpen}>
|
|
101
|
+
<DropdownMenu.Trigger asChild>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
data-prenta-theme-chrome-edit=""
|
|
105
|
+
aria-label={`Edit ${label.toLowerCase()}`}
|
|
106
|
+
title={`Edit ${label.toLowerCase()}`}
|
|
107
|
+
className="border-border bg-card text-foreground hover:bg-accent focus-visible:outline-ring absolute top-2 right-2 z-20 inline-flex h-9 w-9 items-center justify-center rounded-md border shadow-sm"
|
|
108
|
+
>
|
|
109
|
+
<Pencil size={16} aria-hidden />
|
|
110
|
+
</button>
|
|
111
|
+
</DropdownMenu.Trigger>
|
|
112
|
+
<ChromeEditMenu onEditChrome={chromeProps.onEditChrome} />
|
|
113
|
+
</DropdownMenu.Root>
|
|
114
|
+
</div>
|
|
115
|
+
</ThemeChromeRenderProvider>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ChromeEditMenu({ onEditChrome }: { onEditChrome: AdminThemeChromeProps['onEditChrome'] }) {
|
|
120
|
+
const menu = (
|
|
121
|
+
<DropdownMenu.Content
|
|
122
|
+
align="end"
|
|
123
|
+
sideOffset={6}
|
|
124
|
+
className="border-border bg-popover z-[120] min-w-[12rem] rounded-[10px] border p-[5px] shadow-[0_12px_34px_rgba(26,24,39,0.17)]"
|
|
125
|
+
>
|
|
126
|
+
<ChromeEditItem onSelect={() => onEditChrome('navigations')} label="Edit navigation" />
|
|
127
|
+
<ChromeEditItem onSelect={() => onEditChrome('appearance')} label="Edit appearance" />
|
|
128
|
+
<ChromeEditItem onSelect={() => onEditChrome('settings')} label="Edit site settings" />
|
|
129
|
+
</DropdownMenu.Content>
|
|
130
|
+
)
|
|
131
|
+
const portalRoot = adminPortalContainer()
|
|
132
|
+
return portalRoot ? (
|
|
133
|
+
<DropdownMenu.Portal container={portalRoot}>{menu}</DropdownMenu.Portal>
|
|
134
|
+
) : (
|
|
135
|
+
menu
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ChromeEditItem({ onSelect, label }: { onSelect: () => void; label: string }) {
|
|
140
|
+
return (
|
|
141
|
+
<DropdownMenu.Item
|
|
142
|
+
onSelect={onSelect}
|
|
143
|
+
className="hover:bg-accent focus:bg-accent cursor-pointer rounded-md px-2.5 py-1.5 text-sm outline-none"
|
|
144
|
+
>
|
|
145
|
+
{label}
|
|
146
|
+
</DropdownMenu.Item>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ChromeBody({
|
|
151
|
+
region,
|
|
152
|
+
renderer,
|
|
153
|
+
fallback,
|
|
154
|
+
previewSrc,
|
|
155
|
+
chromeProps,
|
|
156
|
+
onStatus,
|
|
157
|
+
}: {
|
|
158
|
+
region: 'header' | 'footer'
|
|
159
|
+
renderer?: AdminThemeChrome['header']
|
|
160
|
+
fallback?: AdminThemeChrome['fallback']
|
|
161
|
+
previewSrc?: string
|
|
162
|
+
chromeProps: AdminThemeChromeProps
|
|
163
|
+
onStatus: (status: ThemeChromeSlotStatus) => void
|
|
164
|
+
}) {
|
|
165
|
+
useLayoutEffect(() => {
|
|
166
|
+
if (renderer) onStatus('ok')
|
|
167
|
+
}, [renderer, onStatus])
|
|
168
|
+
|
|
169
|
+
if (renderer) {
|
|
170
|
+
return <>{renderer(chromeProps)}</>
|
|
171
|
+
}
|
|
172
|
+
return (
|
|
173
|
+
<ChromeFallback
|
|
174
|
+
region={region}
|
|
175
|
+
spec={{ fallback, previewSrc: previewSrc ? { [region]: previewSrc } : undefined }}
|
|
176
|
+
chromeProps={chromeProps}
|
|
177
|
+
previewSrc={previewSrc}
|
|
178
|
+
onStatus={onStatus}
|
|
179
|
+
/>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function ChromeFallback({
|
|
184
|
+
region,
|
|
185
|
+
spec,
|
|
186
|
+
chromeProps,
|
|
187
|
+
previewSrc,
|
|
188
|
+
onStatus,
|
|
189
|
+
}: {
|
|
190
|
+
region: 'header' | 'footer'
|
|
191
|
+
spec: Pick<AdminThemeChrome, 'fallback' | 'previewSrc'>
|
|
192
|
+
chromeProps: AdminThemeChromeProps
|
|
193
|
+
previewSrc?: string
|
|
194
|
+
onStatus: (status: ThemeChromeSlotStatus) => void
|
|
195
|
+
}) {
|
|
196
|
+
const usePreview = Boolean(previewSrc && isSameOriginPreviewSrc(previewSrc))
|
|
197
|
+
const useFallback = Boolean(!usePreview && spec.fallback)
|
|
198
|
+
|
|
199
|
+
useLayoutEffect(() => {
|
|
200
|
+
if (usePreview) onStatus('preview')
|
|
201
|
+
else if (useFallback) onStatus('fallback')
|
|
202
|
+
else onStatus('placeholder')
|
|
203
|
+
}, [usePreview, useFallback, onStatus])
|
|
204
|
+
|
|
205
|
+
if (usePreview && previewSrc) {
|
|
206
|
+
return (
|
|
207
|
+
<iframe
|
|
208
|
+
src={previewSrc}
|
|
209
|
+
title={region === 'header' ? 'Site header preview' : 'Site footer preview'}
|
|
210
|
+
sandbox="allow-same-origin"
|
|
211
|
+
className="block w-full border-0"
|
|
212
|
+
/>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if (useFallback && spec.fallback) {
|
|
216
|
+
return <>{spec.fallback(chromeProps)}</>
|
|
217
|
+
}
|
|
218
|
+
return <ChromePlaceholder region={region} />
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ChromePlaceholder({ region }: { region: 'header' | 'footer' }) {
|
|
222
|
+
const label = region === 'header' ? 'Site header' : 'Site footer'
|
|
223
|
+
return (
|
|
224
|
+
<div
|
|
225
|
+
data-prenta-theme-chrome-placeholder={region}
|
|
226
|
+
className="border-border bg-muted text-muted-foreground flex flex-col items-start gap-1 border-b px-6 py-4 text-left text-sm"
|
|
227
|
+
>
|
|
228
|
+
<p className="text-foreground font-medium">{label}</p>
|
|
229
|
+
<p>
|
|
230
|
+
This site has not supplied a client-safe {region} for the canvas. Use{' '}
|
|
231
|
+
<span className="text-foreground">View site</span> to see the live page.
|
|
232
|
+
</p>
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ThemeChromeProvider,
|
|
3
|
+
useThemeChromeSpec,
|
|
4
|
+
useThemeChromeContext,
|
|
5
|
+
} from './ThemeChromeProvider.js'
|
|
6
|
+
export { ThemeChromeFrame } from './ThemeChromeFrame.js'
|
|
7
|
+
export type { ThemeChromeFrameProps, ThemeChromeHealthState } from './ThemeChromeFrame.js'
|
|
8
|
+
export { ThemeChromeHealth } from './ThemeChromeHealth.js'
|
|
9
|
+
export type { ThemeChromeHealthProps } from './ThemeChromeHealth.js'
|
|
10
|
+
export { ThemeChromeModeToggle } from './ThemeChromeModeToggle.js'
|
|
11
|
+
export type { ThemeChromeModeToggleProps } from './ThemeChromeModeToggle.js'
|
|
12
|
+
export { useThemeChromeMode } from './useThemeChromeMode.js'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
readStoredChromeMode,
|
|
6
|
+
writeStoredChromeMode,
|
|
7
|
+
type ThemeChromeMode,
|
|
8
|
+
} from '../../../lib/theme-chrome.js'
|
|
9
|
+
|
|
10
|
+
export function useThemeChromeMode(defaultMode: ThemeChromeMode = 'flow') {
|
|
11
|
+
const [mode, setModeState] = useState<ThemeChromeMode>(defaultMode)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const stored = readStoredChromeMode()
|
|
15
|
+
if (stored) setModeState(stored)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
const setMode = useCallback((next: ThemeChromeMode) => {
|
|
19
|
+
setModeState(next)
|
|
20
|
+
writeStoredChromeMode(next)
|
|
21
|
+
}, [])
|
|
22
|
+
|
|
23
|
+
return [mode, setMode] as const
|
|
24
|
+
}
|