Rail gets a drag handle on its edge (240-640px, width persisted like the rest of the panel state). Escape now closes the panel as the last step of its chain (dialog > inline > selection > panel). The dev panel remembers its tab across reloads. Panel toggle button title notes the Esc shortcut. Harness covers resize/persist, Esc-close and dev tab. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
763 lines
33 KiB
TypeScript
763 lines
33 KiB
TypeScript
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { InsertPicker } from '../editors/builtin'
|
|
import type { CmsNode, EvaluateContext, ExperimentAnalytics, ExperimentAssignment, ExperimentRule, TypeInfo } from '../api'
|
|
import { allowedChildren, connectionsOf, labelKey, sortGroups, typeName } from '../schema'
|
|
import type { DropTarget } from '../page/dnd'
|
|
import { Icon, TypeIcon } from './Icon'
|
|
import { Summary } from './Summary'
|
|
import { useCtrl, useEditorState, usePersistentState } from './hooks'
|
|
import type { Block, Controller } from '../controller'
|
|
|
|
/**
|
|
* Left panel: outline + blocks + nodes in one place (no tabs).
|
|
*
|
|
* - Outline is a tree with its slots (e.g. a pdp's `info-section` /
|
|
* `3d-accessories` connections): drag any row by its grip to reorder, drop
|
|
* palette blocks and searched nodes onto the gaps between rows or into slots.
|
|
* - Blocks are the new types that fit this page (drag onto the page or the outline).
|
|
* - Nodes finds *existing* nodes (type filter + text) to link into the page.
|
|
*/
|
|
const RAIL_W_MIN = 240
|
|
const RAIL_W_MAX = 640
|
|
|
|
export function Rail() {
|
|
const ctrl = useCtrl()
|
|
const open = useEditorState((s) => s.rail)
|
|
const [width, setWidth] = usePersistentState<number>('cms-onpage.railwidth', 320)
|
|
useEffect(() => {
|
|
if (!open) return
|
|
// Types carry every schema (titles, groups, slots): one request seeds them all.
|
|
void ctrl.api
|
|
.types()
|
|
.then((ts) => {
|
|
ctrl.ingestTypes(ts)
|
|
void ctrl.loadSchemas(ts.map(typeName))
|
|
})
|
|
.catch(() => {})
|
|
void ctrl.fillHandles()
|
|
}, [open, ctrl])
|
|
if (!open) return null
|
|
const w = Math.max(RAIL_W_MIN, Math.min(width, window.innerWidth - 24))
|
|
const startResize = (e: React.PointerEvent) => {
|
|
if (e.button !== 0) return
|
|
e.preventDefault()
|
|
const doc = e.currentTarget.ownerDocument
|
|
const rail = e.currentTarget.parentElement!
|
|
const move = (m: PointerEvent) => {
|
|
const left = rail.getBoundingClientRect().left
|
|
setWidth(Math.round(Math.max(RAIL_W_MIN, Math.min(RAIL_W_MAX, m.clientX - left))))
|
|
}
|
|
const stop = () => {
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
}
|
|
doc.addEventListener('pointermove', move, true)
|
|
doc.addEventListener('pointerup', stop, true)
|
|
}
|
|
return (
|
|
<aside className="cms-rail" aria-label="Page panel" style={{ width: w }}>
|
|
<header>
|
|
<span className="cms-rail__title">
|
|
<Icon name="outline" size={16} /> Page
|
|
</span>
|
|
<button className="cms-ico" onClick={() => ctrl.setRail(false)} aria-label="Close panel" title="Close panel (Esc)">
|
|
<Icon name="close" />
|
|
</button>
|
|
</header>
|
|
<div
|
|
className="cms-rail__resize"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="Resize panel"
|
|
title="Drag to resize"
|
|
onPointerDown={startResize}
|
|
/>
|
|
<div className="cms-rail__body">
|
|
<OutlineTree />
|
|
<Palette />
|
|
<NodeSearch />
|
|
<ExperimentsPanel />
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
// --- outline -----------------------------------------------------------------
|
|
|
|
type Gap = { parentId: string; handle?: string; index: number }
|
|
|
|
function OutlineTree() {
|
|
const ctrl = useCtrl()
|
|
const blocks = useEditorState((s) => s.blocks)
|
|
const selected = useEditorState((s) => s.selected)
|
|
const drag = useEditorState((s) => s.drag)
|
|
useEditorState((s) => s.tick)
|
|
useEditorState((s) => s.schemasVersion)
|
|
useEditorState((s) => s.labelsVersion)
|
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
|
|
const [over, setOver] = useState<Gap | null>(null)
|
|
const dragging = !!drag
|
|
|
|
// Our pointerup runs before the page engine's (registered at mount, theirs at
|
|
// drag start): a drop on a gap commits to the outline, anywhere else on the
|
|
// panel just cancels the page ghost.
|
|
useEffect(() => {
|
|
const onUp = (e: PointerEvent) => {
|
|
const d = ctrl.store.get().drag
|
|
if (!d) return
|
|
const path = e.composedPath() as Element[]
|
|
const inRail = path.some((el) => el instanceof Element && el.classList?.contains('cms-rail'))
|
|
if (!inRail) return
|
|
e.stopImmediatePropagation()
|
|
e.preventDefault()
|
|
const gapEl = path.find((el) => el instanceof Element && el.hasAttribute?.('data-ogap'))
|
|
const target: DropTarget | null = gapEl
|
|
? {
|
|
parentId: gapEl.getAttribute('data-parent') ?? '',
|
|
handle: gapEl.getAttribute('data-handle') || undefined,
|
|
index: Number(gapEl.getAttribute('data-index') ?? 0),
|
|
line: { x: 0, y: 0, w: 0, h: 0 },
|
|
}
|
|
: null
|
|
setOver(null)
|
|
ctrl.cancelDrag()
|
|
if (!target) return
|
|
if (d.source === 'block' && d.id) void ctrl.dropBlock(d.id, target)
|
|
else if (d.source === 'existing' && d.nodeId) void ctrl.dropExisting(target, d.nodeId)
|
|
else if (d.source === 'palette' && d.type && !d.blueprint) void ctrl.dropNew(target, { type: d.type })
|
|
else if (d.source === 'palette' && d.blueprint && !target.handle) void ctrl.dropNew(target, { blueprint: d.blueprint })
|
|
else if (d.source === 'palette' && d.blueprint && target.handle) ctrl.toast('info', 'Blueprints cannot go straight into a slot — drop it on the page first, then move it')
|
|
}
|
|
ctrl.doc.addEventListener('pointerup', onUp, true)
|
|
return () => ctrl.doc.removeEventListener('pointerup', onUp, true)
|
|
}, [ctrl])
|
|
|
|
useEffect(() => {
|
|
if (!drag) setOver(null)
|
|
}, [drag])
|
|
|
|
const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks])
|
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.outline', true)
|
|
if (!blocks.length) return null
|
|
return (
|
|
<section aria-label="Outline">
|
|
<h4 className="cms-rail__sec">
|
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Outline" onClick={() => setOpen((o) => !o)}>
|
|
Outline ({blocks.length})
|
|
</button>
|
|
</h4>
|
|
{open && (
|
|
<>
|
|
<ul className="cms-outline cms-outline--tree">
|
|
{roots.map((b) => (
|
|
<OutlineNode key={b.id} block={b} level={0} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
|
))}
|
|
</ul>
|
|
{dragging && <OutlineGap parentId="" index={roots.length} over={over} setOver={setOver} />}
|
|
</>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function OutlineGap({ parentId, handle, index, over, setOver }: { parentId: string; handle?: string; index: number; over: Gap | null; setOver: (g: Gap | null) => void }) {
|
|
const ctrl = useCtrl()
|
|
const drag = useEditorState((s) => s.drag)
|
|
if (!drag) return null
|
|
const active = over?.parentId === parentId && (over?.handle ?? '') === (handle ?? '') && over?.index === index
|
|
// Blueprints cannot land in slots; slot gaps refuse them.
|
|
const refused = !!drag.blueprint && !!handle
|
|
// Page drops onto a type the slot does not take are refused too.
|
|
const typeRefused = !!drag.type && !drag.blueprint && parentId !== '' && !ctrl.accepts(parentId, drag.type) && !(handle ? ctrl.acceptsInSlot(parentId, handle, drag.type) : ctrl.handleForDrop(parentId, drag.type))
|
|
return (
|
|
<div
|
|
className={`cms-ogap${active && !refused ? ' is-over' : ''}${refused || typeRefused ? ' is-refused' : ''}`}
|
|
data-ogap=""
|
|
data-parent={parentId}
|
|
data-handle={handle ?? ''}
|
|
data-index={index}
|
|
onPointerEnter={() => (refused ? setOver(null) : setOver({ parentId, handle, index }))}
|
|
onPointerLeave={() => setOver(null)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function OutlineNode({ block: b, level, collapsed, setCollapsed, over, setOver, dragging, selected }: {
|
|
block: Block
|
|
level: number
|
|
collapsed: Set<string>
|
|
setCollapsed: (f: (s: Set<string>) => Set<string>) => void
|
|
over: Gap | null
|
|
setOver: (g: Gap | null) => void
|
|
dragging: boolean
|
|
selected: string | null
|
|
}) {
|
|
const ctrl = useCtrl()
|
|
const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === b.id && !ctrl.handleOf(x.id)))
|
|
const slots = useMemo(() => ctrl.slotsFor(b.id), [ctrl, b.id, b.schema, kids.length])
|
|
useEditorState((s) => s.tick)
|
|
const isCollapsed = collapsed.has(b.id)
|
|
const isSel = b.id === selected
|
|
const toggle = () => setCollapsed((s) => {
|
|
const n = new Set(s)
|
|
if (n.has(b.id)) n.delete(b.id)
|
|
else n.add(b.id)
|
|
return n
|
|
})
|
|
const title = ctrl.labelFor(b.id)
|
|
const hasKids = kids.length > 0 || slots.some((sl) => ctrl.slotChildren(b.id, sl.name).length > 0)
|
|
return (
|
|
<li>
|
|
<div className={`cms-orow${isSel ? ' is-sel' : ''}`} style={{ paddingLeft: 4 + level * 14 }}>
|
|
{(b.container || slots.length > 0) && hasKids ? (
|
|
<button className="cms-ico cms-orow__chev" onClick={toggle} aria-label={isCollapsed ? 'Expand' : 'Collapse'} title={isCollapsed ? 'Expand' : 'Collapse'}>
|
|
<span className={`cms-chev${isCollapsed ? '' : ' is-open'}`} style={{ display: 'inline-flex' }}>
|
|
<Icon name="chevron" size={14} />
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<span className="cms-orow__spacer" />
|
|
)}
|
|
<span
|
|
className="cms-orow__grip"
|
|
title="Drag to reorder"
|
|
aria-label={`Drag ${title ?? b.type} to reorder`}
|
|
onPointerDown={(e) => {
|
|
if (e.button !== 0) return
|
|
e.stopPropagation()
|
|
const x0 = e.clientX
|
|
const y0 = e.clientY
|
|
const doc = e.currentTarget.ownerDocument
|
|
const move = (m: PointerEvent) => {
|
|
if (Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
ctrl.setMode('edit')
|
|
ctrl.beginDrag(b.id)
|
|
}
|
|
const stop = () => {
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
}
|
|
doc.addEventListener('pointermove', move, true)
|
|
doc.addEventListener('pointerup', stop, true)
|
|
}}
|
|
>
|
|
<Icon name="grip" size={14} />
|
|
</span>
|
|
<button
|
|
className="cms-orow__main"
|
|
onMouseEnter={() => ctrl.store.set({ hovered: b.id })}
|
|
onMouseLeave={() => ctrl.store.set({ hovered: null })}
|
|
onClick={() => {
|
|
ctrl.setMode('edit')
|
|
ctrl.select(b.id, { scroll: true })
|
|
}}
|
|
title={`${b.type}${ctrl.handleOf(b.id) ? ` · in slot ${ctrl.handleOf(b.id)}` : ''}`}
|
|
>
|
|
<TypeIcon type={b.type} size={15} />
|
|
{title ? (
|
|
<>
|
|
<span className="cms-outline__type">{title}</span>
|
|
<span className="cms-outline__text">{b.type}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="cms-outline__type">{b.type || '?'}</span>
|
|
<span className="cms-outline__text">{(b.el.textContent ?? '').trim().slice(0, 40)}</span>
|
|
</>
|
|
)}
|
|
{ctrl.handleOf(b.id) && <em className="cms-oslot-tag">{ctrl.handleOf(b.id)}</em>}
|
|
</button>
|
|
</div>
|
|
{!isCollapsed && (
|
|
<ul className="cms-outline cms-outline--nested">
|
|
{dragging && <OutlineGap parentId={b.id} index={0} over={over} setOver={setOver} />}
|
|
{kids.map((k, i) => (
|
|
<OutlineNodeInner key={`${k.id}-${i}`} block={k} index={i} level={level + 1} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
|
))}
|
|
{dragging && kids.length > 0 && <OutlineGap parentId={b.id} index={kids.length} over={over} setOver={setOver} />}
|
|
{slots.map((sl) => (
|
|
<SlotGroup key={sl.name} parentId={b.id} level={level + 1} slot={sl} over={over} setOver={setOver} dragging={dragging} collapsed={collapsed} setCollapsed={setCollapsed} selected={selected} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/** A plain (non-slot) child: its own row, followed by the gap after it. */
|
|
function OutlineNodeInner(props: { block: Block; index: number; level: number } & Omit<Parameters<typeof OutlineNode>[0], 'block' | 'level'>) {
|
|
const { block, index, level, ...rest } = props
|
|
return (
|
|
<>
|
|
<OutlineNode block={block} level={level} {...rest} />
|
|
{rest.dragging && <OutlineGap parentId={block.parentId ?? ''} index={index + 1} over={rest.over} setOver={rest.setOver} />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function SlotGroup({ parentId, level, slot, over, setOver, dragging, collapsed, setCollapsed, selected }: {
|
|
parentId: string
|
|
level: number
|
|
slot: { name: string; def: { to: string[]; kind: string; description?: string }; count: number; accepts: (t: string) => boolean }
|
|
over: Gap | null
|
|
setOver: (g: Gap | null) => void
|
|
dragging: boolean
|
|
collapsed: Set<string>
|
|
setCollapsed: (f: (s: Set<string>) => Set<string>) => void
|
|
selected: string | null
|
|
}) {
|
|
const ctrl = useCtrl()
|
|
const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === parentId && ctrl.handleOf(x.id) === slot.name))
|
|
const add = () => {
|
|
let key = 0
|
|
key = ctrl.openDialog({ id: `insert:${parentId}:${slot.name}`, title: `Add to ${slot.name}`, size: 'sm', content: createElement(InsertPicker, { parentId, handle: slot.name, ctrl, close: () => ctrl.closeDialog(key) }) })
|
|
}
|
|
return (
|
|
<li className="cms-oslot" style={{ marginLeft: level * 14 }}>
|
|
<div className="cms-oslot__head" title={slot.def.description ?? `Slot: ${slot.name}`}>
|
|
<Icon name="shape" size={13} />
|
|
<span>{slot.name}</span>
|
|
<span className="cms-muted">{slot.def.to.length ? slot.def.to.join(', ') : 'any'}</span>
|
|
<button className="cms-ico" onClick={add} title={`Add to ${slot.name}`} aria-label={`Add to ${slot.name}`}>
|
|
<Icon name="add" size={14} />
|
|
</button>
|
|
</div>
|
|
<ul className="cms-outline cms-outline--nested">
|
|
{dragging && <OutlineGap parentId={parentId} handle={slot.name} index={0} over={over} setOver={setOver} />}
|
|
{kids.map((k, i) => (
|
|
<OutlineNodeInner key={`${k.id}-${i}`} block={k} index={i} level={level + 1} collapsed={collapsed} setCollapsed={setCollapsed} over={over} setOver={setOver} dragging={dragging} selected={selected} />
|
|
))}
|
|
{dragging && kids.length > 0 && <OutlineGap parentId={parentId} handle={slot.name} index={kids.length} over={over} setOver={setOver} />}
|
|
</ul>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
// --- palette (new blocks) -------------------------------------------------------
|
|
|
|
const DRAG_THRESHOLD = 5
|
|
|
|
type Item = { key: string; label: string; sub?: string; type?: string; blueprint?: string; group: string; description?: string }
|
|
|
|
function Palette() {
|
|
const ctrl = useCtrl()
|
|
const [types, setTypes] = useState<TypeInfo[] | null>(null)
|
|
const [blueprints, setBlueprints] = useState<import('../api').Blueprint[]>([])
|
|
const [q, setQ] = useState('')
|
|
const { selected, blocks } = useEditorState((s) => ({ selected: s.selected, blocks: s.blocks }))
|
|
const schemasVersion = useEditorState((s) => s.schemasVersion)
|
|
const suppressClick = useRef(false)
|
|
|
|
useEffect(() => {
|
|
void ctrl.api
|
|
.types()
|
|
.then((ts) => {
|
|
ctrl.ingestTypes(ts) // /api/types includes every schema: titles + groups with no further requests
|
|
setTypes(ts)
|
|
void ctrl.loadSchemas(ts.map(typeName)) // only for entries that arrived without a schema
|
|
})
|
|
.catch(() => setTypes([]))
|
|
void ctrl.api.blueprints().then(setBlueprints).catch(() => {})
|
|
}, [ctrl])
|
|
|
|
// Types some container on the page (or the page itself) can hold - the rest would only ever be refused.
|
|
const items = useMemo<Item[]>(() => {
|
|
if (!types) return []
|
|
const all = types.map(typeName).filter(Boolean)
|
|
const allowed = new Set<string>()
|
|
for (const b of blocks) if (b.container) allowedChildren(b.schema, all).forEach((t) => allowed.add(t))
|
|
// Slots accept more than plain children (e.g. a pdp's info-section): include slot types too.
|
|
for (const b of blocks) for (const h of Object.keys(connectionsOf(b.schema))) for (const t of connectionsOf(b.schema)[h]?.to ?? []) allowed.add(t)
|
|
const rootAllowed = !allowed.size // schemas not loaded yet: show everything rather than nothing
|
|
return [
|
|
...types
|
|
.map((t) => ({ t, name: typeName(t) }))
|
|
.filter(({ name }) => name && !ctrl.isStaticRoot(name) && (rootAllowed || allowed.has(name)))
|
|
.map(({ name }) => {
|
|
const title = ctrl.typeTitle(name)
|
|
return { key: `t:${name}`, label: title, sub: title !== name ? name : undefined, type: name, group: ctrl.groupOfType(name), description: ctrl.schemaOf(name)?.description as string | undefined }
|
|
}),
|
|
...blueprints.map((b) => ({ key: `b:${b.id}`, label: b.name ?? b.id, blueprint: b.id, group: 'Blueprints', description: b.description })),
|
|
]
|
|
}, [types, blueprints, blocks, ctrl, schemasVersion])
|
|
|
|
const needle = q.toLowerCase()
|
|
const shown = items.filter((i) => i.label.toLowerCase().includes(needle) || (i.type ?? '').toLowerCase().includes(needle))
|
|
const groups = sortGroups(shown.map((i) => i.group).filter((g) => g !== 'Blueprints'))
|
|
if (shown.some((i) => i.group === 'Blueprints')) groups.push('Blueprints')
|
|
|
|
const addToSelection = async (item: Item) => {
|
|
const sel = ctrl.block(selected)
|
|
try {
|
|
let parent: string
|
|
let index: number | undefined
|
|
let handle: string | undefined
|
|
if (sel && sel.container && (!item.type || ctrl.accepts(sel.id, item.type) || (handle = ctrl.handleForDrop(sel.id, item.type)))) parent = sel.id
|
|
else if (sel) {
|
|
parent = await ctrl.parentIdOf(sel.id)
|
|
handle = ctrl.handleOf(sel.id)
|
|
index = (await ctrl.position(sel.id)).index + 1
|
|
} else parent = await ctrl.pageRoot()
|
|
if (item.blueprint) await ctrl.insertBlueprint(parent, item.blueprint, index)
|
|
else if (item.type) await ctrl.insert(parent, item.type, { ...(index !== undefined ? { index } : {}), ...(handle ? { handle } : {}) })
|
|
} catch (e) {
|
|
ctrl.toast('error', (e as Error).message)
|
|
}
|
|
}
|
|
|
|
const onPointerDown = (e: React.PointerEvent, item: Item) => {
|
|
if (e.button !== 0) return
|
|
const x0 = e.clientX
|
|
const y0 = e.clientY
|
|
const doc = e.currentTarget.ownerDocument
|
|
const move = (m: PointerEvent) => {
|
|
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
|
|
stop()
|
|
suppressClick.current = true
|
|
ctrl.setMode('edit')
|
|
ctrl.beginPaletteDrag({ type: item.type, blueprint: item.blueprint, label: item.label })
|
|
}
|
|
const stop = () => {
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
}
|
|
doc.addEventListener('pointermove', move, true)
|
|
doc.addEventListener('pointerup', stop, true)
|
|
}
|
|
|
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.blocks', true)
|
|
return (
|
|
<section aria-label="Blocks">
|
|
<h4 className="cms-rail__sec">
|
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Blocks" onClick={() => setOpen((o) => !o)}>
|
|
Blocks
|
|
</button>
|
|
</h4>
|
|
{open && (
|
|
<>
|
|
<div className="cms-pad">
|
|
<input className="cms-input" placeholder="Search blocks…" aria-label="Search blocks" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
<p className="cms-muted cms-hint">Drag onto the page or the outline, or click to add to the selection.</p>
|
|
</div>
|
|
{!types && <p className="cms-muted cms-pad">Loading…</p>}
|
|
{groups.map((g) => (
|
|
<details key={g} className="cms-group-sec" open>
|
|
<Summary>
|
|
{g} <span className="cms-muted">{shown.filter((i) => i.group === g).length}</span>
|
|
</Summary>
|
|
<div className="cms-palette__grid">
|
|
{shown
|
|
.filter((i) => i.group === g)
|
|
.map((i) => (
|
|
<button
|
|
key={i.key}
|
|
className="cms-block"
|
|
data-type={i.type}
|
|
title={i.description ?? `Add ${i.label}`}
|
|
onPointerDown={(e) => onPointerDown(e, i)}
|
|
onClick={() => {
|
|
if (suppressClick.current) return void (suppressClick.current = false)
|
|
void addToSelection(i)
|
|
}}
|
|
>
|
|
<TypeIcon type={i.type ?? i.label} size={22} />
|
|
<span>{i.label}</span>
|
|
{i.sub && <small>{i.sub}</small>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</details>
|
|
))}
|
|
{types && !shown.length && <p className="cms-muted cms-pad">Nothing matches.</p>}
|
|
</>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// --- node search (existing nodes) -----------------------------------------------
|
|
|
|
function nodeTitle(ctrl: Controller, n: CmsNode): string | undefined {
|
|
const key = labelKey(ctrl.schemaOf(n.dataType))
|
|
const v = key ? (n.data as Record<string, unknown>)?.[key] : undefined
|
|
const text = typeof v === 'string' || typeof v === 'number' ? String(v).replace(/\s+/g, ' ').trim() : ''
|
|
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text
|
|
const first = Object.values(n.data ?? {}).find((x) => typeof x === 'string' && x.trim())
|
|
const fb = typeof first === 'string' ? first.trim().slice(0, 40) : ''
|
|
return fb || undefined
|
|
}
|
|
|
|
function NodeSearch() {
|
|
const ctrl = useCtrl()
|
|
const [typeNames, setTypeNames] = useState<string[]>([])
|
|
const [type, setType] = useState('')
|
|
const [q, setQ] = useState('')
|
|
const [results, setResults] = useState<CmsNode[] | null>(null)
|
|
const [total, setTotal] = useState(0)
|
|
const [busy, setBusy] = useState(false)
|
|
const selected = useEditorState((s) => s.selected)
|
|
useEditorState((s) => s.schemasVersion)
|
|
|
|
useEffect(() => {
|
|
void ctrl.api
|
|
.types()
|
|
.then((ts) => {
|
|
ctrl.ingestTypes(ts)
|
|
setTypeNames(ts.map(typeName).filter(Boolean).sort())
|
|
})
|
|
.catch(() => {})
|
|
}, [ctrl])
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => {
|
|
setBusy(true)
|
|
void ctrl.api
|
|
.searchNodes({ type: type || undefined, q: q || undefined, limit: 20 })
|
|
.then((r) => {
|
|
setResults(r.items)
|
|
setTotal(r.total)
|
|
void ctrl.loadSchemas([...new Set(r.items.map((n) => n.dataType))])
|
|
})
|
|
.catch(() => setResults([]))
|
|
.finally(() => setBusy(false))
|
|
}, q ? 250 : 0)
|
|
return () => clearTimeout(t)
|
|
}, [q, type, ctrl])
|
|
|
|
const linkToSelection = async (node: CmsNode) => {
|
|
const sel = ctrl.block(selected)
|
|
try {
|
|
if (sel && sel.container && (ctrl.accepts(sel.id, node.dataType) || ctrl.handleForDrop(sel.id, node.dataType))) {
|
|
await ctrl.linkExisting(sel.id, node.id, { handle: ctrl.handleForDrop(sel.id, node.dataType) })
|
|
} else if (sel) {
|
|
const parent = await ctrl.parentIdOf(sel.id)
|
|
const { index } = await ctrl.position(sel.id)
|
|
await ctrl.linkExisting(parent, node.id, { handle: ctrl.handleOf(sel.id), index: index + 1 })
|
|
} else {
|
|
await ctrl.linkExisting(await ctrl.pageRoot(), node.id, {})
|
|
}
|
|
} catch (e) {
|
|
ctrl.toast('error', (e as Error).message)
|
|
}
|
|
}
|
|
|
|
const onPointerDown = (e: React.PointerEvent, node: CmsNode) => {
|
|
if (e.button !== 0) return
|
|
const x0 = e.clientX
|
|
const y0 = e.clientY
|
|
const doc = e.currentTarget.ownerDocument
|
|
const move = (m: PointerEvent) => {
|
|
if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return
|
|
stop()
|
|
ctrl.setMode('edit')
|
|
ctrl.beginExistingDrag({ id: node.id, type: node.dataType, label: nodeTitle(ctrl, node) ?? node.dataType })
|
|
}
|
|
const stop = () => {
|
|
doc.removeEventListener('pointermove', move, true)
|
|
doc.removeEventListener('pointerup', stop, true)
|
|
}
|
|
doc.addEventListener('pointermove', move, true)
|
|
doc.addEventListener('pointerup', stop, true)
|
|
}
|
|
|
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.nodes', true)
|
|
return (
|
|
<section aria-label="Nodes">
|
|
<h4 className="cms-rail__sec">
|
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Nodes" onClick={() => setOpen((o) => !o)}>
|
|
Nodes
|
|
</button>
|
|
</h4>
|
|
{open && (
|
|
<>
|
|
<div className="cms-pad cms-nodefind">
|
|
<input className="cms-input" placeholder="Search existing nodes…" aria-label="Search nodes" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
<select className="cms-input" aria-label="Filter by type" value={type} onChange={(e) => setType(e.target.value)}>
|
|
<option value="">All types</option>
|
|
{typeNames.map((t) => (
|
|
<option key={t} value={t}>
|
|
{ctrl.typeTitle(t)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="cms-muted cms-hint">Link a shared block into the page — drag it onto the outline, or click + to add to the selection.</p>
|
|
</div>
|
|
{busy && !results && <p className="cms-muted cms-pad">Searching…</p>}
|
|
{results && !results.length && <p className="cms-muted cms-pad">No nodes match.</p>}
|
|
{!!results?.length && (
|
|
<ul className="cms-nodelist">
|
|
{results.map((n) => (
|
|
<li key={n.id} className="cms-noderow" onPointerDown={(e) => onPointerDown(e, n)} title={`${n.dataType} · ${n.id}`}>
|
|
<TypeIcon type={n.dataType} size={15} />
|
|
<span className="cms-noderow__main">
|
|
<span className="cms-outline__type">{nodeTitle(ctrl, n) ?? n.dataType}</span>
|
|
<span className="cms-outline__text">
|
|
{n.dataType} · {n.id.slice(0, 8)}
|
|
</span>
|
|
</span>
|
|
<button
|
|
className="cms-ico"
|
|
aria-label={`Link ${nodeTitle(ctrl, n) ?? n.id}`}
|
|
title="Link into the page"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
void linkToSelection(n)
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<Icon name="add" size={15} />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{!!results?.length && total > results.length && <p className="cms-muted cms-pad">Showing {results.length} of {total} — refine the search.</p>}
|
|
</>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// --- experiments: QA + eligibility on the page (plan-onpage-experiments-personalisation.md P0) ---------
|
|
|
|
const expCookie = (name: string): string | null => {
|
|
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
|
|
return m ? decodeURIComponent(m[1]) : null
|
|
}
|
|
const setExpCookie = (name: string, value: string | null) => {
|
|
document.cookie = value === null ? `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` : `${name}=${encodeURIComponent(value)}; path=/; SameSite=Lax`
|
|
}
|
|
const targetingSummary = (rules?: ExperimentRule['targeting']): string =>
|
|
!rules?.length ? 'everyone' : rules.map((r) => `${r.field} ${r.op} ${r.value ?? (r.values ?? []).join('|')}`).join(' · ')
|
|
const armNames = (rule: ExperimentRule): string[] => Object.keys(rule.variant_weights ?? {})
|
|
|
|
function ExperimentsPanel() {
|
|
const ctrl = useCtrl()
|
|
const [rules, setRules] = useState<ExperimentRule[] | null>(null)
|
|
const [adminDenied, setAdminDenied] = useState(false)
|
|
const [assign, setAssign] = useState<Record<string, ExperimentAssignment>>({})
|
|
const [stats, setStats] = useState<Record<string, ExperimentAnalytics>>({})
|
|
const [confirmOff, setConfirmOff] = useState<string | null>(null)
|
|
const [tick, setTick] = useState(0) // re-read override cookies after set/clear
|
|
const [open, setOpen] = usePersistentState('cms-onpage.sec.experiments', false)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
let live = true
|
|
const ctx: EvaluateContext = { pagePath: location.pathname, referrer: document.referrer || undefined, language: navigator.language || undefined }
|
|
void ctrl.api
|
|
.trackingConfig()
|
|
.then((cfg) => {
|
|
if (!live) return
|
|
const rs = cfg.experiment_rules ?? []
|
|
setRules(rs)
|
|
if (!rs.length) return
|
|
void ctrl.api.evaluateExperiments(rs.map((r) => r.experiment_id), ctx).then(
|
|
(res) => live && setAssign(Object.fromEntries((res.assignments ?? []).map((a) => [a.experimentId, a]))),
|
|
() => {}, // evaluate is public; a failure just leaves assignments blank
|
|
)
|
|
void ctrl.api.experimentAnalytics().then(
|
|
(rows) => live && setStats(Object.fromEntries((rows ?? []).map((s) => [s.experiment_id, s]))),
|
|
() => live && setAdminDenied(true), // admin-gated: eligibility still works, stats hide
|
|
)
|
|
})
|
|
.catch(() => live && (setRules([]), setAdminDenied(true)))
|
|
return () => void (live = false)
|
|
}, [open, ctrl])
|
|
|
|
const toggleDisabled = async (rule: ExperimentRule) => {
|
|
if (confirmOff !== rule.experiment_id) return void setConfirmOff(rule.experiment_id)
|
|
setConfirmOff(null)
|
|
try {
|
|
const res = await ctrl.api.setExperimentDisabled(rule.experiment_id, !rule.disabled)
|
|
setRules((rs) => rs?.map((r) => (r.experiment_id === rule.experiment_id ? { ...r, disabled: res.disabled } : r)) ?? rs)
|
|
ctrl.toast('info', `${rule.experiment_id} ${res.disabled ? 'paused' : 'resumed'}`)
|
|
} catch (e) {
|
|
ctrl.toast('error', (e as Error).message)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section aria-label="Experiments panel">
|
|
<h4 className="cms-rail__sec">
|
|
<button className="cms-sectoggle" aria-expanded={open} aria-label="Experiments" onClick={() => setOpen((o) => !o)}>
|
|
Experiments{rules ? ` (${rules.length})` : ''}
|
|
</button>
|
|
</h4>
|
|
{open && (
|
|
<div className="cms-pad">
|
|
{rules === null && <p className="cms-muted">Loading experiments…</p>}
|
|
{rules !== null && !rules.length && <p className="cms-muted">No experiments configured in tracking.</p>}
|
|
{adminDenied && !!rules?.length && <p className="cms-muted cms-hint">Tracking admin refused — eligibility still works; stats and pause need an admin credential.</p>}
|
|
{!!rules?.length && (
|
|
<ul className="cms-explist">
|
|
{rules.map((rule) => {
|
|
const a = assign[rule.experiment_id]
|
|
const s = stats[rule.experiment_id]
|
|
const arms = armNames(rule)
|
|
const cookie = expCookie(a?.cookieName ?? `exp_${rule.experiment_id}`)
|
|
void tick
|
|
return (
|
|
<li key={rule.experiment_id} className="cms-exp">
|
|
<div className="cms-exp__head">
|
|
<strong>{rule.experiment_id}</strong>
|
|
<span className={`cms-pill${rule.disabled ? ' is-off' : ''}`}>{rule.disabled ? 'paused' : 'live'}</span>
|
|
</div>
|
|
<div className="cms-muted cms-exp__meta">
|
|
traffic {Math.round((rule.traffic_fraction ?? 1) * 100)}% · arms {arms.length ? arms.join(' / ') : 'a / b'} · {targetingSummary(rule.targeting)}
|
|
</div>
|
|
<div className="cms-exp__assign">
|
|
{a ? (a.eligible ? <>you: <b>{a.variant || '—'}</b></> : 'you: not eligible') : 'you: …'}
|
|
{cookie && cookie !== a?.variant && <span className="cms-muted"> (override: {cookie})</span>}
|
|
</div>
|
|
{s && (
|
|
<div className="cms-muted cms-exp__meta">
|
|
{s.total_sessions} sessions · winner <b>{s.winner || '—'}</b>
|
|
{s.winner ? ` (${Math.round((s.winner_probability ?? 0) * 100)}%)` : ''}
|
|
{s.significant ? ' · significant' : ''} — {s.conclusion || 'collecting data'}
|
|
</div>
|
|
)}
|
|
<div className="cms-exp__actions">
|
|
<label className="cms-muted">
|
|
QA arm{' '}
|
|
<select
|
|
className="cms-input"
|
|
aria-label={`Force variant for ${rule.experiment_id}`}
|
|
value={cookie ?? ''}
|
|
onChange={(e) => {
|
|
setExpCookie(a?.cookieName ?? `exp_${rule.experiment_id}`, e.target.value || null)
|
|
setTick((t) => t + 1)
|
|
}}
|
|
>
|
|
<option value="">session default</option>
|
|
{arms.map((v) => (
|
|
<option key={v} value={v}>
|
|
{v}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className={`cms-ico${confirmOff === rule.experiment_id ? ' is-on' : ''}`}
|
|
aria-label={rule.disabled ? `Resume ${rule.experiment_id}` : `Pause ${rule.experiment_id}`}
|
|
title={rule.disabled ? 'Resume (re-buckets new sessions)' : 'Pause (kill-switch: new sessions see control)'}
|
|
onClick={() => void toggleDisabled(rule)}
|
|
>
|
|
<Icon name={confirmOff === rule.experiment_id ? 'check' : rule.disabled ? 'play' : 'pause'} size={15} />
|
|
{confirmOff === rule.experiment_id && <span>confirm</span>}
|
|
</button>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
<p className="cms-muted cms-hint">Overrides set the storefront variant cookie — reload the page to preview the arm.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|