Add on-page editor for the go-graph-cms
Injectable React editor that is side-loaded onto the statically rendered page and edits through the CMS HTTP API (POST /api/edit). - Shadow-root UI over server HTML; patches morphed in, undo/redo from server inverses (reuses @k6n/cms-editor EditClient/morph/markdown editor) - Sign-in via go-auth device grant as the CMS client (aud matches), token refresh + 401 retry; live preview swapped in on load and on tab focus - Schema-driven property form (arrays, maps, variants, $ref, validation) - Every string property editable where it is rendered (value-bound inline fields and attribute chips), markdown WYSIWYG for marked fields - Block palette + drag & drop with ghost drop targets, insertion slots, sort snapping between siblings (Alt to nest) - x-style annotations: on-page padding/margin/gap handles, Style panel, wrap-in-layout; x-graph label/group for titles and grouping - Dev workflow: Vite proxy to the live site (token injected server-side) or an offline in-memory mock CMS; jsdom test suite Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1af9ec3172
67 files changed
+12322
No files matched your search
@@ -0,0 +1,183 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { JSONSchema } from '../api'
|
||||
import { Icon } from '../ui/Icon'
|
||||
import { useForm } from './context'
|
||||
import { JsonArea, Widget } from './Form'
|
||||
import { defaultFor, resolveSchema, variantsOf } from './resolve'
|
||||
|
||||
let uid = 0
|
||||
const newKey = () => `k${++uid}`
|
||||
|
||||
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
|
||||
|
||||
/** Short label for a collapsed object card: x-label, else the first useful string. */
|
||||
function summarise(root: JSONSchema, itemSchema: JSONSchema, item: unknown, index: number): string {
|
||||
if (typeof item === 'string' || typeof item === 'number') return String(item)
|
||||
if (item && typeof item === 'object') {
|
||||
const o = item as Record<string, unknown>
|
||||
const label = itemSchema['x-label'] ?? itemSchema['x-graph']?.label
|
||||
const prefer = [label, 'title', 'name', 'label', 'text', 'heading', 'href', 'id'].filter(Boolean) as string[]
|
||||
for (const k of prefer) if (typeof o[k] === 'string' && o[k]) return String(o[k]).slice(0, 48)
|
||||
for (const v of Object.values(o)) if (typeof v === 'string' && v) return v.slice(0, 48)
|
||||
}
|
||||
return `Item ${index + 1}`
|
||||
}
|
||||
|
||||
export function ArrayField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
|
||||
const { root, errors } = useForm()
|
||||
const items = Array.isArray(value) ? value : []
|
||||
const it = resolveSchema(root, Array.isArray(s.items) ? undefined : s.items)
|
||||
const itemSchema = it.schema
|
||||
|
||||
// Tuples / untyped arrays have no per-item form: raw JSON is the honest editor.
|
||||
if (Array.isArray(s.items) || s.prefixItems) return <JsonArea value={value} onChange={onChange} />
|
||||
|
||||
// uniqueItems + enum items: a checkbox set (the natural UI for "pick several").
|
||||
if (Array.isArray(itemSchema.enum) && s.uniqueItems) {
|
||||
return (
|
||||
<div className="cms-checks">
|
||||
{itemSchema.enum.map((o: unknown) => (
|
||||
<label key={String(o)} className="cms-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="cms-check"
|
||||
checked={items.includes(o)}
|
||||
onChange={(e) => onChange(e.target.checked ? [...items, o] : items.filter((x) => x !== o))}
|
||||
/>{' '}
|
||||
{String(o)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const t = Array.isArray(itemSchema.type) ? itemSchema.type[0] : itemSchema.type
|
||||
const variants = variantsOf(itemSchema)
|
||||
const compound = t === 'object' || t === 'array' || (!t && !!itemSchema.properties) || !!variants?.some((v) => resolveSchema(root, v).schema.type === 'object')
|
||||
return <ArrayList schema={s} itemSchema={itemSchema} items={items} onChange={onChange} path={path} compound={compound} err={errors[path]} />
|
||||
}
|
||||
|
||||
function ArrayList({ schema: s, itemSchema, items, onChange, path, compound }: { schema: JSONSchema; itemSchema: JSONSchema; items: unknown[]; onChange(v: unknown): void; path: string; compound: boolean; err?: string }) {
|
||||
const { root } = useForm()
|
||||
// Stable keys survive reorders, so open/closed state and focus stay with the item.
|
||||
const keys = useRef<string[]>([])
|
||||
while (keys.current.length < items.length) keys.current.push(newKey())
|
||||
if (keys.current.length > items.length) keys.current.length = items.length
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({})
|
||||
const [dragFrom, setDragFrom] = useState<number | null>(null)
|
||||
const [dragOver, setDragOver] = useState<number | null>(null)
|
||||
const min = s.minItems ?? 0
|
||||
const max = s.maxItems ?? Infinity
|
||||
const isOpen = (k: string) => open[k] ?? items.length <= 2
|
||||
|
||||
const commit = (next: unknown[]) => onChange(next)
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= items.length || from === to) return
|
||||
const next = [...items]
|
||||
next.splice(to, 0, ...next.splice(from, 1))
|
||||
keys.current.splice(to, 0, ...keys.current.splice(from, 1))
|
||||
commit(next)
|
||||
}
|
||||
const remove = (i: number) => {
|
||||
keys.current.splice(i, 1)
|
||||
commit(items.filter((_, j) => j !== i))
|
||||
}
|
||||
const duplicate = (i: number) => {
|
||||
const next = [...items]
|
||||
next.splice(i + 1, 0, structuredClone(items[i]))
|
||||
keys.current.splice(i + 1, 0, newKey())
|
||||
commit(next)
|
||||
}
|
||||
const add = () => {
|
||||
if (items.length >= max) return
|
||||
const k = newKey()
|
||||
keys.current.push(k)
|
||||
setOpen((o) => ({ ...o, [k]: true }))
|
||||
commit([...items, defaultFor(root, itemSchema)])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cms-array" data-count={items.length}>
|
||||
<ol className="cms-array__list">
|
||||
{items.map((item, i) => {
|
||||
const k = keys.current[i]
|
||||
const expanded = !compound || isOpen(k)
|
||||
return (
|
||||
<li
|
||||
key={k}
|
||||
className={`cms-item${dragOver === i && dragFrom !== null && dragFrom !== i ? ' is-over' : ''}`}
|
||||
onDragOver={(e) => {
|
||||
if (dragFrom === null) return
|
||||
e.preventDefault()
|
||||
setDragOver(i)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
if (dragFrom !== null) move(dragFrom, i)
|
||||
setDragFrom(null)
|
||||
setDragOver(null)
|
||||
}}
|
||||
>
|
||||
<div className="cms-item__head">
|
||||
<span
|
||||
className="cms-grip"
|
||||
draggable
|
||||
title="Drag to reorder"
|
||||
aria-hidden="true"
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(i))
|
||||
setDragFrom(i)
|
||||
}}
|
||||
onDragEnd={() => (setDragFrom(null), setDragOver(null))}
|
||||
>
|
||||
<Icon name="grip" size={18} />
|
||||
</span>
|
||||
{compound ? (
|
||||
<button type="button" className="cms-item__title" aria-expanded={expanded} onClick={() => setOpen((o) => ({ ...o, [k]: !expanded }))}>
|
||||
<Icon name="chevron" size={16} className={`cms-chev${expanded ? ' is-open' : ''}`} /> {summarise(root, itemSchema, item, i)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="cms-item__value">
|
||||
<Widget schema={itemSchema} value={item} onChange={(v) => commit(items.map((x, j) => (j === i ? (v === undefined ? defaultFor(root, itemSchema) : v) : x)))} path={`${path}.${i}`} required />
|
||||
</div>
|
||||
)}
|
||||
<span className="cms-item__actions">
|
||||
<button type="button" className="cms-ico" aria-label={`Move item ${i + 1} up`} title="Move up" disabled={i === 0} onClick={() => move(i, i - 1)}>
|
||||
<Icon name="up" size={16} />
|
||||
</button>
|
||||
<button type="button" className="cms-ico" aria-label={`Move item ${i + 1} down`} title="Move down" disabled={i === items.length - 1} onClick={() => move(i, i + 1)}>
|
||||
<Icon name="down" size={16} />
|
||||
</button>
|
||||
{compound && (
|
||||
<button type="button" className="cms-ico" aria-label={`Duplicate item ${i + 1}`} title="Duplicate" disabled={items.length >= max} onClick={() => duplicate(i)}>
|
||||
<Icon name="duplicate" size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="cms-ico cms-ico--danger" aria-label={`Remove item ${i + 1}`} title="Remove" disabled={items.length <= min} onClick={() => remove(i)}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{compound && expanded && (
|
||||
<div className="cms-item__body">
|
||||
<Widget schema={itemSchema} value={item} onChange={(v) => commit(items.map((x, j) => (j === i ? (v ?? {}) : x)))} path={`${path}.${i}`} required bare />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
<div className="cms-array__foot">
|
||||
<button type="button" className="cms-btn" aria-label="Add item" onClick={add} disabled={items.length >= max}>
|
||||
<Icon name="add" size={16} /> Add {itemSchema.title?.toLowerCase() ?? 'item'}
|
||||
</button>
|
||||
<span className="cms-muted">
|
||||
{plural(items.length, 'item')}
|
||||
{s.minItems ? ` · min ${s.minItems}` : ''}
|
||||
{s.maxItems ? ` · max ${s.maxItems}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user