The outline and blocks are no longer tabs: one panel shows the outline tree, the new-block palette and a search over existing nodes. Outline rows drag to reorder (same snap engine, explicit gap targets), palette blocks and searched nodes drop onto the outline gaps as well as the page. Named slots (x-graph.connections, e.g. a pdp's info-section / 3d-accessories / model) render as groups under their block with allowed types, live counts and a slot-limited insert picker. Drops that fit no plain-children rule resolve to the first accepting slot; existing nodes link via graft tree.link. Adds Api.searchNodes, schema connections helpers, handle-aware controller ops (linkExisting, insertIntoSlot, dropExisting) and mock support for /api/nodes listing and graft links. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
171 lines
5.8 KiB
TypeScript
171 lines
5.8 KiB
TypeScript
// Thin client for the CMS HTTP API (docs/onpage-editor.md §3-4). Same origin as
|
|
// the page, so cookies flow; a bearer token from the backoffice login is added
|
|
// when present. Every call is recorded so the dev panel can show it.
|
|
import type { EditResult, Op } from '@k6n/cms-editor'
|
|
import { AuthManager } from './auth'
|
|
|
|
export interface CmsNode {
|
|
id: string
|
|
dataType: string
|
|
data: Record<string, any>
|
|
tags?: string[]
|
|
version?: number
|
|
}
|
|
export interface CmsEdge {
|
|
id: string
|
|
from: string
|
|
to: string
|
|
type?: string
|
|
handle?: string
|
|
sort: number
|
|
}
|
|
export interface NodeEdges {
|
|
from: CmsEdge[]
|
|
into: CmsEdge[]
|
|
}
|
|
export interface TypeInfo {
|
|
type: string
|
|
[k: string]: unknown
|
|
}
|
|
export interface Blueprint {
|
|
id: string
|
|
name?: string
|
|
description?: string
|
|
[k: string]: unknown
|
|
}
|
|
export type JSONSchema = Record<string, any>
|
|
|
|
export interface RequestLog {
|
|
n: number
|
|
method: string
|
|
url: string
|
|
status: number
|
|
ms: number
|
|
request?: unknown
|
|
response?: unknown
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(message: string, readonly status: number) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
}
|
|
}
|
|
|
|
export interface ApiOptions {
|
|
base?: string
|
|
fetch?: typeof fetch
|
|
/** Auth: tokens, refresh and the sign-in flow. Default: a fresh AuthManager. */
|
|
auth?: AuthManager
|
|
onLog?: (e: RequestLog) => void
|
|
}
|
|
|
|
export class Api {
|
|
private n = 0
|
|
private schemas = new Map<string, Promise<JSONSchema>>()
|
|
private typesP?: Promise<TypeInfo[]>
|
|
private blueprintsP?: Promise<Blueprint[]>
|
|
readonly doFetch: typeof fetch
|
|
readonly auth: AuthManager
|
|
|
|
constructor(readonly opts: ApiOptions = {}) {
|
|
this.auth = opts.auth ?? new AuthManager({ fetch: opts.fetch })
|
|
const raw = opts.fetch ?? ((...a: Parameters<typeof fetch>) => fetch(...a))
|
|
// Every request: fresh token in, one transparent refresh+retry on 401, sign-out if that fails too.
|
|
this.doFetch = async (input, init) => {
|
|
await this.auth.ensureFresh()
|
|
const send = () => raw(input, { credentials: 'same-origin', ...init, headers: { ...(init?.headers as Record<string, string>), ...this.auth.headers() } })
|
|
let resp = await send()
|
|
if (resp.status === 401 && (await this.auth.refresh())) resp = await send()
|
|
if (resp.status === 401) this.auth.markSignedOut()
|
|
return resp
|
|
}
|
|
}
|
|
|
|
headers(): HeadersInit {
|
|
return this.auth.headers()
|
|
}
|
|
|
|
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const t0 = performance.now()
|
|
const url = `${this.opts.base ?? ''}${path}`
|
|
let status = 0
|
|
let json: unknown
|
|
try {
|
|
const resp = await this.doFetch(url, {
|
|
method,
|
|
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
})
|
|
status = resp.status
|
|
const text = await resp.text()
|
|
try {
|
|
json = text ? JSON.parse(text) : undefined
|
|
} catch {
|
|
json = text
|
|
}
|
|
if (!resp.ok) throw new ApiError((json as any)?.error ?? resp.statusText ?? `HTTP ${status}`, status)
|
|
return json as T
|
|
} finally {
|
|
this.opts.onLog?.({ n: ++this.n, method, url, status, ms: Math.round(performance.now() - t0), request: body, response: json })
|
|
}
|
|
}
|
|
|
|
/** A fetch that records into the request log (handed to EditClient so /api/edit shows up too). */
|
|
loggedFetch: typeof fetch = async (input, init) => {
|
|
const t0 = performance.now()
|
|
const resp = await this.doFetch(input, init)
|
|
let request: unknown
|
|
try {
|
|
request = init?.body ? JSON.parse(String(init.body)) : undefined
|
|
} catch {
|
|
/* not JSON */
|
|
}
|
|
const text = await resp.clone().text().catch(() => '')
|
|
let response: unknown = text
|
|
try {
|
|
response = JSON.parse(text)
|
|
} catch {
|
|
/* keep text */
|
|
}
|
|
this.opts.onLog?.({ n: ++this.n, method: init?.method ?? 'GET', url: String(input), status: resp.status, ms: Math.round(performance.now() - t0), request, response })
|
|
return resp
|
|
}
|
|
|
|
get = <T>(path: string) => this.request<T>('GET', path)
|
|
post = <T>(path: string, body?: unknown) => this.request<T>('POST', path, body)
|
|
|
|
me = () => this.get<{ [k: string]: unknown }>('/api/auth/me')
|
|
node = (id: string) => this.get<CmsNode>(`/api/nodes/${encodeURIComponent(id)}`)
|
|
/** Find existing nodes: `GET /api/nodes?type=&q=&limit=&offset=`. Empty filter lists everything (paged). */
|
|
async searchNodes(opts: { type?: string; q?: string; limit?: number; offset?: number } = {}): Promise<{ items: CmsNode[]; total: number }> {
|
|
const p = new URLSearchParams()
|
|
if (opts.type) p.set('type', opts.type)
|
|
if (opts.q) p.set('q', opts.q)
|
|
p.set('limit', String(opts.limit ?? 20))
|
|
if (opts.offset) p.set('offset', String(opts.offset))
|
|
const r = await this.get<{ items: CmsNode[]; total: number }>(`/api/nodes?${p}`)
|
|
return { items: r.items ?? [], total: r.total ?? (r.items ?? []).length }
|
|
}
|
|
edges = (id: string) => this.get<NodeEdges>(`/api/nodes/${encodeURIComponent(id)}/edges`)
|
|
edit = (ops: Op[]) => this.post<EditResult>('/api/edit', { ops })
|
|
publish = (ids: string[]) => this.post<{ publishedAt?: string; results?: { id: string; error?: string }[] }>('/api/publish-roots', { ids })
|
|
|
|
/** Schemas are code: cache for the session. */
|
|
schema(type: string): Promise<JSONSchema> {
|
|
let p = this.schemas.get(type)
|
|
if (!p) {
|
|
p = this.get<JSONSchema>(`/api/schemas/${encodeURIComponent(type)}`)
|
|
p.catch(() => this.schemas.delete(type))
|
|
this.schemas.set(type, p)
|
|
}
|
|
return p
|
|
}
|
|
types(): Promise<TypeInfo[]> {
|
|
return (this.typesP ??= this.get<TypeInfo[] | { types: TypeInfo[] }>('/api/types').then((r) => (Array.isArray(r) ? r : r.types ?? [])))
|
|
}
|
|
blueprints(): Promise<Blueprint[]> {
|
|
return (this.blueprintsP ??= this.get<Blueprint[]>('/api/blueprints').catch(() => []))
|
|
}
|
|
}
|