@payload-onpage/react 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/OnPageClient.d.ts +18 -0
- package/dist/OnPageClient.d.ts.map +1 -0
- package/dist/OnPageClient.js +51 -0
- package/dist/OnPageClient.js.map +1 -0
- package/dist/annotations.d.ts +55 -0
- package/dist/annotations.d.ts.map +1 -0
- package/dist/annotations.js +60 -0
- package/dist/annotations.js.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +19 -0
- package/dist/context.js.map +1 -0
- package/dist/dom.d.ts +42 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +95 -0
- package/dist/dom.js.map +1 -0
- package/dist/editor/FieldHost.d.ts +5 -0
- package/dist/editor/FieldHost.d.ts.map +1 -0
- package/dist/editor/FieldHost.js +36 -0
- package/dist/editor/FieldHost.js.map +1 -0
- package/dist/editor/Ghosts.d.ts +6 -0
- package/dist/editor/Ghosts.d.ts.map +1 -0
- package/dist/editor/Ghosts.js +37 -0
- package/dist/editor/Ghosts.js.map +1 -0
- package/dist/editor/OnPageEditor.d.ts +25 -0
- package/dist/editor/OnPageEditor.d.ts.map +1 -0
- package/dist/editor/OnPageEditor.js +211 -0
- package/dist/editor/OnPageEditor.js.map +1 -0
- package/dist/editor/useGhosts.d.ts +24 -0
- package/dist/editor/useGhosts.d.ts.map +1 -0
- package/dist/editor/useGhosts.js +34 -0
- package/dist/editor/useGhosts.js.map +1 -0
- package/dist/editor/usePageEvents.d.ts +3 -0
- package/dist/editor/usePageEvents.d.ts.map +1 -0
- package/dist/editor/usePageEvents.js +164 -0
- package/dist/editor/usePageEvents.js.map +1 -0
- package/dist/editor-kit.d.ts +19 -0
- package/dist/editor-kit.d.ts.map +1 -0
- package/dist/editor-kit.js +17 -0
- package/dist/editor-kit.js.map +1 -0
- package/dist/editors/CheckboxEditor.d.ts +3 -0
- package/dist/editors/CheckboxEditor.d.ts.map +1 -0
- package/dist/editors/CheckboxEditor.js +10 -0
- package/dist/editors/CheckboxEditor.js.map +1 -0
- package/dist/editors/ColorEditor.d.ts +4 -0
- package/dist/editors/ColorEditor.d.ts.map +1 -0
- package/dist/editors/ColorEditor.js +21 -0
- package/dist/editors/ColorEditor.js.map +1 -0
- package/dist/editors/DateEditor.d.ts +3 -0
- package/dist/editors/DateEditor.d.ts.map +1 -0
- package/dist/editors/DateEditor.js +28 -0
- package/dist/editors/DateEditor.js.map +1 -0
- package/dist/editors/InlineText.d.ts +4 -0
- package/dist/editors/InlineText.d.ts.map +1 -0
- package/dist/editors/InlineText.js +77 -0
- package/dist/editors/InlineText.js.map +1 -0
- package/dist/editors/NumberEditor.d.ts +4 -0
- package/dist/editors/NumberEditor.d.ts.map +1 -0
- package/dist/editors/NumberEditor.js +23 -0
- package/dist/editors/NumberEditor.js.map +1 -0
- package/dist/editors/RelationshipEditor.d.ts +3 -0
- package/dist/editors/RelationshipEditor.d.ts.map +1 -0
- package/dist/editors/RelationshipEditor.js +60 -0
- package/dist/editors/RelationshipEditor.js.map +1 -0
- package/dist/editors/SelectEditor.d.ts +4 -0
- package/dist/editors/SelectEditor.d.ts.map +1 -0
- package/dist/editors/SelectEditor.js +36 -0
- package/dist/editors/SelectEditor.js.map +1 -0
- package/dist/editors/UploadEditor.d.ts +3 -0
- package/dist/editors/UploadEditor.d.ts.map +1 -0
- package/dist/editors/UploadEditor.js +48 -0
- package/dist/editors/UploadEditor.js.map +1 -0
- package/dist/editors/index.d.ts +13 -0
- package/dist/editors/index.d.ts.map +1 -0
- package/dist/editors/index.js +23 -0
- package/dist/editors/index.js.map +1 -0
- package/dist/editors/useDebouncedSearch.d.ts +17 -0
- package/dist/editors/useDebouncedSearch.d.ts.map +1 -0
- package/dist/editors/useDebouncedSearch.js +50 -0
- package/dist/editors/useDebouncedSearch.js.map +1 -0
- package/dist/hub.d.ts +14 -0
- package/dist/hub.d.ts.map +1 -0
- package/dist/hub.js +20 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +12 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +83 -0
- package/dist/layout.js.map +1 -0
- package/dist/locators.d.ts +19 -0
- package/dist/locators.d.ts.map +1 -0
- package/dist/locators.js +215 -0
- package/dist/locators.js.map +1 -0
- package/dist/messages.d.ts +100 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +112 -0
- package/dist/messages.js.map +1 -0
- package/dist/registry.d.ts +15 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +29 -0
- package/dist/registry.js.map +1 -0
- package/dist/renderers.d.ts +7 -0
- package/dist/renderers.d.ts.map +1 -0
- package/dist/renderers.js +12 -0
- package/dist/renderers.js.map +1 -0
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +49 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/styles.d.ts +8 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +122 -0
- package/dist/styles.js.map +1 -0
- package/dist/targets.d.ts +18 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +86 -0
- package/dist/targets.js.map +1 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/BlockPicker.d.ts +3 -0
- package/dist/ui/BlockPicker.d.ts.map +1 -0
- package/dist/ui/BlockPicker.js +27 -0
- package/dist/ui/BlockPicker.js.map +1 -0
- package/dist/ui/Dialog.d.ts +22 -0
- package/dist/ui/Dialog.d.ts.map +1 -0
- package/dist/ui/Dialog.js +83 -0
- package/dist/ui/Dialog.js.map +1 -0
- package/dist/ui/Dock.d.ts +7 -0
- package/dist/ui/Dock.d.ts.map +1 -0
- package/dist/ui/Dock.js +104 -0
- package/dist/ui/Dock.js.map +1 -0
- package/dist/ui/Overlay.d.ts +2 -0
- package/dist/ui/Overlay.d.ts.map +1 -0
- package/dist/ui/Overlay.js +150 -0
- package/dist/ui/Overlay.js.map +1 -0
- package/dist/ui/PostButton.d.ts +10 -0
- package/dist/ui/PostButton.d.ts.map +1 -0
- package/dist/ui/PostButton.js +7 -0
- package/dist/ui/PostButton.js.map +1 -0
- package/dist/ui/PreviewToolbar.d.ts +15 -0
- package/dist/ui/PreviewToolbar.d.ts.map +1 -0
- package/dist/ui/PreviewToolbar.js +45 -0
- package/dist/ui/PreviewToolbar.js.map +1 -0
- package/dist/ui/SettingsPanel.d.ts +11 -0
- package/dist/ui/SettingsPanel.d.ts.map +1 -0
- package/dist/ui/SettingsPanel.js +130 -0
- package/dist/ui/SettingsPanel.js.map +1 -0
- package/dist/useEvent.d.ts +7 -0
- package/dist/useEvent.d.ts.map +1 -0
- package/dist/useEvent.js +15 -0
- package/dist/useEvent.js.map +1 -0
- package/package.json +71 -4
- package/src/OnPageClient.tsx +67 -0
- package/src/annotations.ts +83 -0
- package/src/context.tsx +23 -0
- package/src/dom.ts +118 -0
- package/src/editor/FieldHost.tsx +66 -0
- package/src/editor/Ghosts.tsx +55 -0
- package/src/editor/OnPageEditor.tsx +315 -0
- package/src/editor/useGhosts.ts +64 -0
- package/src/editor/usePageEvents.ts +161 -0
- package/src/editor-kit.ts +27 -0
- package/src/editors/CheckboxEditor.tsx +31 -0
- package/src/editors/ColorEditor.tsx +64 -0
- package/src/editors/DateEditor.tsx +64 -0
- package/src/editors/InlineText.tsx +106 -0
- package/src/editors/NumberEditor.tsx +69 -0
- package/src/editors/RelationshipEditor.tsx +153 -0
- package/src/editors/SelectEditor.tsx +74 -0
- package/src/editors/UploadEditor.tsx +130 -0
- package/src/editors/index.ts +35 -0
- package/src/editors/useDebouncedSearch.ts +54 -0
- package/src/hub.ts +32 -0
- package/src/index.ts +55 -0
- package/src/layout.ts +83 -0
- package/src/locators.ts +210 -0
- package/src/messages.ts +120 -0
- package/src/registry.ts +43 -0
- package/src/renderers.ts +15 -0
- package/src/sanitize.ts +52 -0
- package/src/styles.ts +122 -0
- package/src/targets.ts +101 -0
- package/src/types.ts +132 -0
- package/src/ui/BlockPicker.tsx +49 -0
- package/src/ui/Dialog.tsx +159 -0
- package/src/ui/Dock.tsx +240 -0
- package/src/ui/Overlay.tsx +324 -0
- package/src/ui/PostButton.tsx +26 -0
- package/src/ui/PreviewToolbar.tsx +123 -0
- package/src/ui/SettingsPanel.tsx +279 -0
- package/src/useEvent.ts +16 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Preview mode: the draft without editing tools. Device widths open the same
|
|
5
|
+
* page in a framed viewport. Signed-in editors can switch back to editing;
|
|
6
|
+
* people on a shared link can leave to the published page.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
9
|
+
import { createPortal } from 'react-dom'
|
|
10
|
+
import { UI } from '../dom.ts'
|
|
11
|
+
import { createTranslate, type Messages } from '../messages.ts'
|
|
12
|
+
import { injectStyles } from '../styles.ts'
|
|
13
|
+
import { PostButton } from './PostButton.tsx'
|
|
14
|
+
|
|
15
|
+
export interface PreviewToolbarProps {
|
|
16
|
+
/** Endpoint that leaves preview (POST), back to the published page. */
|
|
17
|
+
exitHref: string
|
|
18
|
+
/** Endpoint that switches to editing (POST); omit for people who can't edit (shared links). */
|
|
19
|
+
editHref?: string
|
|
20
|
+
/** Opened from a shared preview link. */
|
|
21
|
+
shared?: boolean
|
|
22
|
+
messages?: Messages
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEVICES = [
|
|
26
|
+
{ id: 'phone', width: 390 },
|
|
27
|
+
{ id: 'tablet', width: 820 },
|
|
28
|
+
] as const
|
|
29
|
+
|
|
30
|
+
export const FRAME_PARAM = 'onpage-frame'
|
|
31
|
+
|
|
32
|
+
/** True inside the device-preview frame (the toolbar and editor stay out of it). */
|
|
33
|
+
export const inPreviewFrame = (): boolean =>
|
|
34
|
+
typeof window !== 'undefined' &&
|
|
35
|
+
(window.self !== window.top || new URLSearchParams(window.location.search).has(FRAME_PARAM))
|
|
36
|
+
|
|
37
|
+
const noop = () => () => {}
|
|
38
|
+
const useMounted = () =>
|
|
39
|
+
useSyncExternalStore(
|
|
40
|
+
noop,
|
|
41
|
+
() => true,
|
|
42
|
+
() => false,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
export function PreviewToolbar({ exitHref, editHref, shared, messages }: PreviewToolbarProps) {
|
|
46
|
+
const [device, setDevice] = useState<(typeof DEVICES)[number] | null>(null)
|
|
47
|
+
const mounted = useMounted()
|
|
48
|
+
const t = useMemo(() => createTranslate(messages), [messages])
|
|
49
|
+
useEffect(() => injectStyles(), [])
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!device) return
|
|
52
|
+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setDevice(null)
|
|
53
|
+
document.addEventListener('keydown', onKey)
|
|
54
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
55
|
+
}, [device])
|
|
56
|
+
if (!mounted || inPreviewFrame()) return null
|
|
57
|
+
|
|
58
|
+
const frameSrc = () => {
|
|
59
|
+
const u = new URL(window.location.href)
|
|
60
|
+
u.searchParams.set(FRAME_PARAM, '1')
|
|
61
|
+
return u.toString()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return createPortal(
|
|
65
|
+
<div className="po-layer" {...{ [UI]: '' }}>
|
|
66
|
+
{device && (
|
|
67
|
+
<div className="po-frame-bg" data-testid="onpage-device-frame">
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className="po-frame-close"
|
|
71
|
+
onClick={() => setDevice(null)}
|
|
72
|
+
aria-label={t('common.close')}
|
|
73
|
+
/>
|
|
74
|
+
<iframe
|
|
75
|
+
src={frameSrc()}
|
|
76
|
+
title={t(`preview.${device.id}`)}
|
|
77
|
+
style={{ width: device.width }}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
<div
|
|
82
|
+
className="po-preview"
|
|
83
|
+
role="toolbar"
|
|
84
|
+
aria-label={t('preview.label')}
|
|
85
|
+
data-testid="onpage-preview-toolbar"
|
|
86
|
+
>
|
|
87
|
+
<span className="po-badge">{t('preview.badge')}</span>
|
|
88
|
+
<span className="po-status">{t(shared ? 'preview.shared' : 'preview.own')}</span>
|
|
89
|
+
{DEVICES.map((d) => (
|
|
90
|
+
<button
|
|
91
|
+
key={d.id}
|
|
92
|
+
type="button"
|
|
93
|
+
className="po-btn"
|
|
94
|
+
aria-pressed={device?.id === d.id}
|
|
95
|
+
onClick={() => setDevice(device?.id === d.id ? null : d)}
|
|
96
|
+
data-testid={`onpage-device-${d.id}`}
|
|
97
|
+
>
|
|
98
|
+
{t(`preview.${d.id}`)}
|
|
99
|
+
</button>
|
|
100
|
+
))}
|
|
101
|
+
{device && (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="po-btn"
|
|
105
|
+
onClick={() => setDevice(null)}
|
|
106
|
+
data-testid="onpage-device-desktop"
|
|
107
|
+
>
|
|
108
|
+
{t('preview.desktop')}
|
|
109
|
+
</button>
|
|
110
|
+
)}
|
|
111
|
+
{editHref && (
|
|
112
|
+
<PostButton action={editHref} className="po-btn po-primary" testId="onpage-edit">
|
|
113
|
+
{t('preview.edit')}
|
|
114
|
+
</PostButton>
|
|
115
|
+
)}
|
|
116
|
+
<PostButton action={exitHref} testId="onpage-exit-preview">
|
|
117
|
+
{t(shared ? 'preview.viewPublished' : 'preview.exit')}
|
|
118
|
+
</PostButton>
|
|
119
|
+
</div>
|
|
120
|
+
</div>,
|
|
121
|
+
document.body,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings for one section or item, generated from the schema: every value
|
|
5
|
+
* that isn't edited directly on the page — selects (layout, background, width,
|
|
6
|
+
* spacing…), checkboxes, numbers, dates, relationships, URLs and other plain
|
|
7
|
+
* text, images that aren't on the page. Changes apply immediately.
|
|
8
|
+
*/
|
|
9
|
+
import { fieldAtPath, getAt, relationTitle, toText, type SchemaField } from '@payload-onpage/core'
|
|
10
|
+
import { useId, useRef, useState, type KeyboardEvent, type RefObject } from 'react'
|
|
11
|
+
import { useOnPage } from '../context.tsx'
|
|
12
|
+
import type { Translate } from '../messages.ts'
|
|
13
|
+
import type { TargetIndex } from '../types.ts'
|
|
14
|
+
import { Dialog } from './Dialog.tsx'
|
|
15
|
+
|
|
16
|
+
const SKIP = new Set(['id', 'blockType', 'blockName'])
|
|
17
|
+
const TEXTUAL = new Set(['text', 'textarea', 'email', 'number', 'date', 'relationship', 'upload'])
|
|
18
|
+
const INPUT_TYPES = new Set(['text', 'textarea', 'email', 'number'])
|
|
19
|
+
|
|
20
|
+
function settable(field: SchemaField, path: string, key: string, targets: TargetIndex): boolean {
|
|
21
|
+
if (SKIP.has(field.name)) return false
|
|
22
|
+
const full = `${path}.${field.name}`
|
|
23
|
+
switch (field.type) {
|
|
24
|
+
case 'select':
|
|
25
|
+
case 'radio':
|
|
26
|
+
case 'checkbox':
|
|
27
|
+
return true
|
|
28
|
+
case 'group':
|
|
29
|
+
return (field.fields ?? []).some((f) => settable(f, full, key, targets))
|
|
30
|
+
default:
|
|
31
|
+
// Shown on the page? Edit it there.
|
|
32
|
+
return TEXTUAL.has(field.type) && !targets.find(key, full)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const hasSettings = (
|
|
37
|
+
fields: SchemaField[],
|
|
38
|
+
path: string,
|
|
39
|
+
key: string,
|
|
40
|
+
targets: TargetIndex,
|
|
41
|
+
): boolean => fields.some((f) => settable(f, path, key, targets))
|
|
42
|
+
|
|
43
|
+
const display = (v: unknown, t: Translate): string => {
|
|
44
|
+
if (v == null || v === '') return t('common.none')
|
|
45
|
+
if (Array.isArray(v))
|
|
46
|
+
return v.length ? t('common.selected', { count: v.length }) : t('common.none')
|
|
47
|
+
return typeof v === 'object' ? relationTitle(v) : toText(v)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function TextInput({
|
|
51
|
+
docKey,
|
|
52
|
+
path,
|
|
53
|
+
field,
|
|
54
|
+
id,
|
|
55
|
+
}: {
|
|
56
|
+
docKey: string
|
|
57
|
+
path: string
|
|
58
|
+
field: SchemaField
|
|
59
|
+
id: string
|
|
60
|
+
}) {
|
|
61
|
+
const { session, snap } = useOnPage()
|
|
62
|
+
const value = getAt(snap.docs[docKey]?.doc, path)
|
|
63
|
+
const [draft, setDraft] = useState<string | null>(null)
|
|
64
|
+
const shown = draft ?? toText(value)
|
|
65
|
+
const parse = (v: string) => (field.type === 'number' ? (v === '' ? null : Number(v)) : v)
|
|
66
|
+
const commit = () => {
|
|
67
|
+
if (draft === null) return
|
|
68
|
+
session.setValue(docKey, path, parse(draft))
|
|
69
|
+
setDraft(null)
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<input
|
|
73
|
+
id={id}
|
|
74
|
+
className="po-input"
|
|
75
|
+
type={field.type === 'number' ? 'number' : field.type === 'email' ? 'email' : 'text'}
|
|
76
|
+
value={shown}
|
|
77
|
+
onChange={(e) => {
|
|
78
|
+
setDraft(e.target.value)
|
|
79
|
+
session.setValue(docKey, path, parse(e.target.value), { transient: true })
|
|
80
|
+
}}
|
|
81
|
+
onBlur={commit}
|
|
82
|
+
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
|
83
|
+
data-testid={`onpage-setting-${path}`}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A radio group with arrow-key navigation (WAI-ARIA radio pattern). */
|
|
89
|
+
function Segmented({
|
|
90
|
+
docKey,
|
|
91
|
+
path,
|
|
92
|
+
field,
|
|
93
|
+
labelId,
|
|
94
|
+
}: {
|
|
95
|
+
docKey: string
|
|
96
|
+
path: string
|
|
97
|
+
field: SchemaField
|
|
98
|
+
labelId: string
|
|
99
|
+
}) {
|
|
100
|
+
const { session, snap } = useOnPage()
|
|
101
|
+
const options = field.options ?? []
|
|
102
|
+
const current = getAt(snap.docs[docKey]?.doc, path) ?? field.defaultValue ?? ''
|
|
103
|
+
const index = Math.max(
|
|
104
|
+
0,
|
|
105
|
+
options.findIndex((o) => o.value === current),
|
|
106
|
+
)
|
|
107
|
+
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
108
|
+
const step =
|
|
109
|
+
e.key === 'ArrowRight' || e.key === 'ArrowDown'
|
|
110
|
+
? 1
|
|
111
|
+
: e.key === 'ArrowLeft' || e.key === 'ArrowUp'
|
|
112
|
+
? -1
|
|
113
|
+
: 0
|
|
114
|
+
if (!step) return
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
const next = options[(index + step + options.length) % options.length]
|
|
117
|
+
if (next) session.setValue(docKey, path, next.value)
|
|
118
|
+
const group = e.currentTarget.parentElement
|
|
119
|
+
requestAnimationFrame(() =>
|
|
120
|
+
group?.querySelector<HTMLButtonElement>('[aria-checked="true"]')?.focus(),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return (
|
|
124
|
+
<div className="po-seg" role="radiogroup" aria-labelledby={labelId}>
|
|
125
|
+
{options.map((o, i) => (
|
|
126
|
+
<button
|
|
127
|
+
key={o.value}
|
|
128
|
+
type="button"
|
|
129
|
+
role="radio"
|
|
130
|
+
aria-checked={current === o.value}
|
|
131
|
+
tabIndex={i === index ? 0 : -1}
|
|
132
|
+
onClick={() => session.setValue(docKey, path, o.value)}
|
|
133
|
+
onKeyDown={onKey}
|
|
134
|
+
data-testid={`onpage-setting-${path}-${o.value}`}
|
|
135
|
+
>
|
|
136
|
+
{o.label}
|
|
137
|
+
</button>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function Control({
|
|
144
|
+
docKey,
|
|
145
|
+
path,
|
|
146
|
+
field,
|
|
147
|
+
anchor,
|
|
148
|
+
id,
|
|
149
|
+
labelId,
|
|
150
|
+
}: {
|
|
151
|
+
docKey: string
|
|
152
|
+
path: string
|
|
153
|
+
field: SchemaField
|
|
154
|
+
anchor: RefObject<HTMLDivElement | null>
|
|
155
|
+
id: string
|
|
156
|
+
labelId: string
|
|
157
|
+
}) {
|
|
158
|
+
const { session, snap, edit, t } = useOnPage()
|
|
159
|
+
const value = getAt(snap.docs[docKey]?.doc, path)
|
|
160
|
+
const set = (v: unknown) => session.setValue(docKey, path, v)
|
|
161
|
+
if ((field.type === 'select' || field.type === 'radio') && !field.hasMany) {
|
|
162
|
+
const options = field.options ?? []
|
|
163
|
+
if (options.length <= 5)
|
|
164
|
+
return <Segmented docKey={docKey} path={path} field={field} labelId={labelId} />
|
|
165
|
+
return (
|
|
166
|
+
<select
|
|
167
|
+
id={id}
|
|
168
|
+
className="po-select"
|
|
169
|
+
value={toText(value ?? field.defaultValue)}
|
|
170
|
+
onChange={(e) => set(e.target.value || null)}
|
|
171
|
+
data-testid={`onpage-setting-${path}`}
|
|
172
|
+
>
|
|
173
|
+
{!field.required && <option value="">{t('common.none')}</option>}
|
|
174
|
+
{options.map((o) => (
|
|
175
|
+
<option key={o.value} value={o.value}>
|
|
176
|
+
{o.label}
|
|
177
|
+
</option>
|
|
178
|
+
))}
|
|
179
|
+
</select>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
if (field.type === 'checkbox') {
|
|
183
|
+
return (
|
|
184
|
+
<label className="po-check">
|
|
185
|
+
<input
|
|
186
|
+
type="checkbox"
|
|
187
|
+
checked={Boolean(value ?? field.defaultValue)}
|
|
188
|
+
onChange={(e) => set(e.target.checked)}
|
|
189
|
+
data-testid={`onpage-setting-${path}`}
|
|
190
|
+
/>
|
|
191
|
+
{field.label}
|
|
192
|
+
</label>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
if (INPUT_TYPES.has(field.type))
|
|
196
|
+
return <TextInput docKey={docKey} path={path} field={field} id={id} />
|
|
197
|
+
// Everything else opens its field editor, anchored to the panel row.
|
|
198
|
+
return (
|
|
199
|
+
<button
|
|
200
|
+
id={id}
|
|
201
|
+
type="button"
|
|
202
|
+
className="po-input po-picker"
|
|
203
|
+
onClick={() => edit(docKey, path, { anchor: anchor.current ?? undefined })}
|
|
204
|
+
data-testid={`onpage-setting-${path}`}
|
|
205
|
+
>
|
|
206
|
+
{field.type === 'upload'
|
|
207
|
+
? t(value ? 'settings.changeImage' : 'settings.chooseImage')
|
|
208
|
+
: display(value, t)}
|
|
209
|
+
</button>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function Row({ docKey, path, field }: { docKey: string; path: string; field: SchemaField }) {
|
|
214
|
+
const anchor = useRef<HTMLDivElement>(null)
|
|
215
|
+
const id = useId()
|
|
216
|
+
const labelId = `${id}-label`
|
|
217
|
+
return (
|
|
218
|
+
<div className="po-row" ref={anchor}>
|
|
219
|
+
{field.type !== 'checkbox' && (
|
|
220
|
+
<label id={labelId} htmlFor={id}>
|
|
221
|
+
{field.label}
|
|
222
|
+
</label>
|
|
223
|
+
)}
|
|
224
|
+
<Control
|
|
225
|
+
docKey={docKey}
|
|
226
|
+
path={path}
|
|
227
|
+
field={field}
|
|
228
|
+
anchor={anchor}
|
|
229
|
+
id={id}
|
|
230
|
+
labelId={labelId}
|
|
231
|
+
/>
|
|
232
|
+
{field.description && <small>{field.description}</small>}
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function Fields({ docKey, fields, path }: { docKey: string; fields: SchemaField[]; path: string }) {
|
|
238
|
+
const { targets } = useOnPage()
|
|
239
|
+
return (
|
|
240
|
+
<>
|
|
241
|
+
{fields
|
|
242
|
+
.filter((f) => settable(f, path, docKey, targets))
|
|
243
|
+
.map((f) => {
|
|
244
|
+
const full = `${path}.${f.name}`
|
|
245
|
+
if (f.type === 'group') {
|
|
246
|
+
return (
|
|
247
|
+
<fieldset key={f.name} className="po-group">
|
|
248
|
+
<legend className="po-h4">{f.label}</legend>
|
|
249
|
+
<Fields docKey={docKey} fields={f.fields ?? []} path={full} />
|
|
250
|
+
</fieldset>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
return <Row key={f.name} docKey={docKey} path={full} field={f} />
|
|
254
|
+
})}
|
|
255
|
+
</>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function SettingsPanel() {
|
|
260
|
+
const { panel, setPanel, snap, t } = useOnPage()
|
|
261
|
+
if (panel?.kind !== 'settings') return null
|
|
262
|
+
const state = snap.docs[panel.key]
|
|
263
|
+
if (!state?.schema || !state.doc) return null
|
|
264
|
+
const owner = fieldAtPath(state.schema.fields, state.doc, panel.path)
|
|
265
|
+
// Row fields: the block's fields for a blocks row, the item fields for an array row.
|
|
266
|
+
const fields =
|
|
267
|
+
owner?.field.type === 'blocks' ? (owner.block?.fields ?? []) : (owner?.field.fields ?? [])
|
|
268
|
+
return (
|
|
269
|
+
<Dialog
|
|
270
|
+
variant="panel"
|
|
271
|
+
title={t('settings.title', { label: panel.title })}
|
|
272
|
+
onClose={() => setPanel(null)}
|
|
273
|
+
t={t}
|
|
274
|
+
testId="onpage-settings"
|
|
275
|
+
>
|
|
276
|
+
<Fields docKey={panel.key} fields={fields} path={panel.path} />
|
|
277
|
+
</Dialog>
|
|
278
|
+
)
|
|
279
|
+
}
|
package/src/useEvent.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useInsertionEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A stable function that always calls the latest `fn` (the `useEffectEvent`
|
|
7
|
+
* pattern). Our own, because the React bundled with Next.js 15 doesn't export
|
|
8
|
+
* `useEffectEvent`. Call it from effects and event handlers, not during render.
|
|
9
|
+
*/
|
|
10
|
+
export function useEvent<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
|
|
11
|
+
const latest = useRef(fn)
|
|
12
|
+
useInsertionEffect(() => {
|
|
13
|
+
latest.current = fn
|
|
14
|
+
})
|
|
15
|
+
return useCallback((...args: A) => latest.current(...args), [])
|
|
16
|
+
}
|