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,118 @@
|
||||
import { useState } from 'react'
|
||||
import type { JSONSchema } from '../api'
|
||||
import { Icon } from '../ui/Icon'
|
||||
import { useForm } from './context'
|
||||
import { Widget } from './Form'
|
||||
import { defaultFor, resolveSchema } from './resolve'
|
||||
|
||||
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
* Key → value editor: `additionalProperties` schemas, `x-editor: dictionary`, objects without a
|
||||
* declared shape. With `x-dict-keys` the keys are a fixed vocabulary (one row each, blank removes);
|
||||
* otherwise keys are free-form (rename, add, remove).
|
||||
*/
|
||||
export function MapField({ schema: s, value, onChange, path }: { schema: JSONSchema; value: unknown; onChange(v: unknown): void; path: string }) {
|
||||
const { root } = useForm()
|
||||
const obj = isObj(value) ? value : {}
|
||||
const ap = s.additionalProperties
|
||||
const valueSchema: JSONSchema = ap && typeof ap === 'object' ? ap : {}
|
||||
const fixed = Array.isArray(s['x-dict-keys']) ? (s['x-dict-keys'] as string[]) : null
|
||||
const [draftKey, setDraftKey] = useState('')
|
||||
const [dupe, setDupe] = useState(false)
|
||||
|
||||
const setVal = (k: string, v: unknown) => {
|
||||
const next = { ...obj }
|
||||
if (fixed && (v === undefined || v === '')) delete next[k]
|
||||
else next[k] = v === undefined ? defaultFor(root, valueSchema) : v
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
if (fixed) {
|
||||
const rest = Object.keys(obj).filter((k) => !fixed.includes(k))
|
||||
return (
|
||||
<div className="cms-map">
|
||||
{[...fixed, ...rest].map((k) => (
|
||||
<div key={k} className="cms-map__row">
|
||||
<span className="cms-map__key">{k}</span>
|
||||
<div className="cms-map__value">
|
||||
<Widget schema={valueSchema} value={obj[k]} onChange={(v) => setVal(k, v)} path={`${path}.${k}`} />
|
||||
</div>
|
||||
{k in obj && (
|
||||
<button type="button" className="cms-ico cms-ico--danger" aria-label={`Clear ${k}`} onClick={() => setVal(k, undefined)}>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rename = (from: string, to: string) => {
|
||||
to = to.trim()
|
||||
if (!to || to === from) return false
|
||||
if (to in obj) return setDupe(true), false
|
||||
// Rebuild to keep the row's position.
|
||||
onChange(Object.fromEntries(Object.entries(obj).map(([k, v]) => [k === from ? to : k, v])))
|
||||
return true
|
||||
}
|
||||
const add = () => {
|
||||
const k = draftKey.trim()
|
||||
if (!k) return
|
||||
if (k in obj) return setDupe(true)
|
||||
onChange({ ...obj, [k]: defaultFor(root, valueSchema) })
|
||||
setDraftKey('')
|
||||
setDupe(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cms-map">
|
||||
{Object.entries(obj).map(([k, v]) => (
|
||||
<div key={k} className="cms-map__row">
|
||||
<KeyInput name={k} onCommit={(to) => rename(k, to)} />
|
||||
<div className="cms-map__value">
|
||||
<Widget schema={valueSchema} value={v} onChange={(nv) => setVal(k, nv)} path={`${path}.${k}`} required />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cms-ico cms-ico--danger"
|
||||
aria-label={`Remove ${k}`}
|
||||
onClick={() => {
|
||||
const { [k]: _gone, ...rest } = obj
|
||||
onChange(rest)
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="cms-map__add">
|
||||
<input
|
||||
className={`cms-input${dupe ? ' cms-input--bad' : ''}`}
|
||||
placeholder="new key"
|
||||
aria-label="New key"
|
||||
value={draftKey}
|
||||
onChange={(e) => (setDraftKey(e.target.value), setDupe(false))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
|
||||
/>
|
||||
<button type="button" className="cms-btn" aria-label="Add entry" onClick={add} disabled={!draftKey.trim()}>
|
||||
<Icon name="add" size={16} /> Add
|
||||
</button>
|
||||
</div>
|
||||
{dupe && <span className="cms-field__error">That key already exists</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Key text is local until blur/Enter: renaming rebuilds the object, which would otherwise steal focus per keystroke. */
|
||||
function KeyInput({ name, onCommit }: { name: string; onCommit(to: string): boolean }) {
|
||||
const [text, setText] = useState(name)
|
||||
const [seen, setSeen] = useState(name)
|
||||
if (seen !== name) {
|
||||
setSeen(name)
|
||||
setText(name)
|
||||
}
|
||||
const commit = () => (onCommit(text) ? undefined : setText(name))
|
||||
return <input className="cms-input cms-map__key cms-mono" aria-label={`Key ${name}`} value={text} onChange={(e) => setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), commit())} />
|
||||
}
|
||||
Reference in new issue
Block a user