@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts +10 -0
  3. package/dist/AdminRoot.d.ts.map +1 -1
  4. package/dist/AdminRoot.js +2 -1
  5. package/dist/AdminRoot.js.map +1 -1
  6. package/dist/__tests__/assets/prenta-logo.test.d.ts +2 -0
  7. package/dist/__tests__/assets/prenta-logo.test.d.ts.map +1 -0
  8. package/dist/__tests__/assets/prenta-logo.test.js +26 -0
  9. package/dist/__tests__/assets/prenta-logo.test.js.map +1 -0
  10. package/dist/__tests__/layout/sidebar-brand.render.test.js +13 -0
  11. package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -1
  12. package/dist/__tests__/lib/theme-chrome.test.d.ts +2 -0
  13. package/dist/__tests__/lib/theme-chrome.test.d.ts.map +1 -0
  14. package/dist/__tests__/lib/theme-chrome.test.js +104 -0
  15. package/dist/__tests__/lib/theme-chrome.test.js.map +1 -0
  16. package/dist/__tests__/views/editor-top-bar.render.test.js +11 -0
  17. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  18. package/dist/__tests__/views/login-branding.render.test.js +2 -1
  19. package/dist/__tests__/views/login-branding.render.test.js.map +1 -1
  20. package/dist/__tests__/views/theme-chrome-frame.render.test.d.ts +2 -0
  21. package/dist/__tests__/views/theme-chrome-frame.render.test.d.ts.map +1 -0
  22. package/dist/__tests__/views/theme-chrome-frame.render.test.js +236 -0
  23. package/dist/__tests__/views/theme-chrome-frame.render.test.js.map +1 -0
  24. package/dist/assets/prenta-logo.d.ts +15 -7
  25. package/dist/assets/prenta-logo.d.ts.map +1 -1
  26. package/dist/assets/prenta-logo.js +12 -4
  27. package/dist/assets/prenta-logo.js.map +1 -1
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/layout/Sidebar.d.ts.map +1 -1
  33. package/dist/layout/Sidebar.js +2 -12
  34. package/dist/layout/Sidebar.js.map +1 -1
  35. package/dist/lib/theme-chrome.d.ts +87 -0
  36. package/dist/lib/theme-chrome.d.ts.map +1 -0
  37. package/dist/lib/theme-chrome.js +117 -0
  38. package/dist/lib/theme-chrome.js.map +1 -0
  39. package/dist/prenta-admin.css +1 -1
  40. package/dist/views/Login.d.ts +1 -1
  41. package/dist/views/Login.d.ts.map +1 -1
  42. package/dist/views/Login.js +4 -3
  43. package/dist/views/Login.js.map +1 -1
  44. package/dist/views/page-editor/CanvasSurface.d.ts +14 -2
  45. package/dist/views/page-editor/CanvasSurface.d.ts.map +1 -1
  46. package/dist/views/page-editor/CanvasSurface.js +30 -14
  47. package/dist/views/page-editor/CanvasSurface.js.map +1 -1
  48. package/dist/views/page-editor/EditorCanvas.d.ts +12 -1
  49. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  50. package/dist/views/page-editor/EditorCanvas.js +22 -5
  51. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  52. package/dist/views/page-editor/EditorTopBar.d.ts +5 -1
  53. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  54. package/dist/views/page-editor/EditorTopBar.js +3 -2
  55. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  56. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  57. package/dist/views/page-editor/PageSectionEditor.js +30 -2
  58. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  59. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.d.ts +21 -0
  60. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.d.ts.map +1 -0
  61. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.js +34 -0
  62. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.js.map +1 -0
  63. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.d.ts +12 -0
  64. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.d.ts.map +1 -0
  65. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.js +12 -0
  66. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.js.map +1 -0
  67. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.d.ts +11 -0
  68. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.d.ts.map +1 -0
  69. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.js +15 -0
  70. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.js.map +1 -0
  71. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.d.ts +18 -0
  72. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.d.ts.map +1 -0
  73. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.js +23 -0
  74. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.js.map +1 -0
  75. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.d.ts +11 -0
  76. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.d.ts.map +1 -0
  77. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.js +82 -0
  78. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.js.map +1 -0
  79. package/dist/views/page-editor/theme-chrome/index.d.ts +9 -0
  80. package/dist/views/page-editor/theme-chrome/index.d.ts.map +1 -0
  81. package/dist/views/page-editor/theme-chrome/index.js +6 -0
  82. package/dist/views/page-editor/theme-chrome/index.js.map +1 -0
  83. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.d.ts +3 -0
  84. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.d.ts.map +1 -0
  85. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.js +17 -0
  86. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.js.map +1 -0
  87. package/package.json +3 -3
  88. package/src/AdminRoot.tsx +14 -1
  89. package/src/__tests__/assets/prenta-logo.test.tsx +26 -0
  90. package/src/__tests__/layout/sidebar-brand.render.test.tsx +15 -0
  91. package/src/__tests__/lib/theme-chrome.test.ts +132 -0
  92. package/src/__tests__/views/editor-top-bar.render.test.tsx +13 -0
  93. package/src/__tests__/views/login-branding.render.test.tsx +2 -1
  94. package/src/__tests__/views/theme-chrome-frame.render.test.tsx +304 -0
  95. package/src/assets/prenta-logo.tsx +47 -21
  96. package/src/index.ts +17 -0
  97. package/src/layout/Sidebar.tsx +2 -27
  98. package/src/lib/theme-chrome.ts +172 -0
  99. package/src/views/Login.tsx +5 -4
  100. package/src/views/page-editor/CanvasSurface.tsx +70 -24
  101. package/src/views/page-editor/EditorCanvas.tsx +89 -15
  102. package/src/views/page-editor/EditorTopBar.tsx +10 -0
  103. package/src/views/page-editor/PageSectionEditor.tsx +42 -0
  104. package/src/views/page-editor/theme-chrome/ThemeChromeFrame.tsx +119 -0
  105. package/src/views/page-editor/theme-chrome/ThemeChromeHealth.tsx +66 -0
  106. package/src/views/page-editor/theme-chrome/ThemeChromeModeToggle.tsx +63 -0
  107. package/src/views/page-editor/theme-chrome/ThemeChromeProvider.tsx +43 -0
  108. package/src/views/page-editor/theme-chrome/ThemeChromeSlot.tsx +235 -0
  109. package/src/views/page-editor/theme-chrome/index.ts +12 -0
  110. 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
+ }