Files
cms-onpageeditor/src/api.ts
T
mats 33bd5269fd Single panel: outline tree with slots, node search with type filter
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>
2026-11-06 20:38:31 +01:00

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(() => []))
}
}