@pramen/cms-editor 0.0.59 → 0.0.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/editor.css +1 -1
- package/dist/editor.js +166 -163
- package/package.json +1 -1
- package/src/api.ts +80 -2
- package/src/app-context.tsx +42 -3
- package/src/blockkit.tsx +410 -0
- package/src/buzola.gen.ts +117 -23
- package/src/chrome.ts +16 -0
- package/src/components.tsx +102 -10
- package/src/fields.tsx +257 -2
- package/src/furniture.tsx +924 -0
- package/src/nav.ts +134 -0
- package/src/routes/_layout.tsx +36 -54
- package/src/routes/admin-page.tsx +36 -0
- package/src/routes/block-type.tsx +29 -0
- package/src/routes/content-type.tsx +32 -0
- package/src/routes/menu.tsx +30 -0
- package/src/routes/menus.tsx +18 -0
- package/src/routes/page.tsx +1 -1
- package/src/routes/redirects.tsx +17 -0
- package/src/routes/schema.tsx +34 -0
- package/src/routes/taxonomies.tsx +18 -0
- package/src/routes/taxonomy.tsx +29 -0
- package/src/routes/widget-area.tsx +29 -0
- package/src/routes/widgets.tsx +18 -0
- package/src/schema-builder.tsx +944 -0
- package/src/types.ts +237 -1
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
// HTTP client for the CMS handlers. Bearer token + the pramen `{ ok, result }` envelope,
|
|
2
2
|
// same transport shape as @pramen/admin's api.ts. Config is persisted in localStorage.
|
|
3
3
|
|
|
4
|
-
import type { AssembledPage, AuditEntry, BlockType, ContentType, Media, Page } from "./types";
|
|
5
|
-
import type { RpcInput } from "./types";
|
|
4
|
+
import type { AdminPageMeta, AdminPageResponse, AssembledPage, AuditEntry, BlockType, ContentType, Media, Menu, MenuItem, Page, Redirect, Taxonomy, Term, Widget, WidgetArea } from "./types";
|
|
5
|
+
import type { DefaultBlockDefinition, FieldDefinition, RegionDefinition, RpcInput } from "./types";
|
|
6
|
+
|
|
7
|
+
/** The payload `createBlockType` / `updateBlockType` take. `fieldsSchema` is the whole
|
|
8
|
+
* point: a block type IS its field schema. */
|
|
9
|
+
export interface BlockTypeInput {
|
|
10
|
+
name: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
description?: string | null;
|
|
13
|
+
icon?: string | null;
|
|
14
|
+
category?: string | null;
|
|
15
|
+
fieldsSchema?: FieldDefinition[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The payload `createContentType` / `updateContentType` take. */
|
|
19
|
+
export interface ContentTypeInput {
|
|
20
|
+
name: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
regions?: RegionDefinition[];
|
|
23
|
+
fieldsSchema?: FieldDefinition[];
|
|
24
|
+
defaultBlocks?: DefaultBlockDefinition[];
|
|
25
|
+
}
|
|
6
26
|
|
|
7
27
|
export interface Config {
|
|
8
28
|
baseUrl: string;
|
|
@@ -164,6 +184,64 @@ export class Api {
|
|
|
164
184
|
restoreMedia = (id: string) => this.call<{ ok: true }>("restoreMedia", { id });
|
|
165
185
|
purgeMedia = (id: string) => this.call<{ ok: true }>("purgeMedia", { id });
|
|
166
186
|
|
|
187
|
+
// --- block types & content types (the schema behind pages) ---
|
|
188
|
+
//
|
|
189
|
+
// Read wrappers existed from the start; the WRITE half did not, which is why a fresh CMS
|
|
190
|
+
// could not be bootstrapped from the editor at all — `createBlockType` / `createContentType`
|
|
191
|
+
// had to be curled before the editor was usable (GitHub #9). The handlers were already
|
|
192
|
+
// there and already editor-gated; only these and the screens over them were missing.
|
|
193
|
+
createBlockType = (input: BlockTypeInput) => this.call<BlockType>("createBlockType", input as unknown as RpcInput);
|
|
194
|
+
/** Patch a block type. `slug` is the stable key and is NOT mutable server-side. */
|
|
195
|
+
updateBlockType = (id: string, input: Partial<BlockTypeInput>) => this.call<BlockType>("updateBlockType", { id, ...input } as unknown as RpcInput);
|
|
196
|
+
createContentType = (input: ContentTypeInput) => this.call<ContentType>("createContentType", input as unknown as RpcInput);
|
|
197
|
+
updateContentType = (id: string, input: Partial<ContentTypeInput>) => this.call<ContentType>("updateContentType", { id, ...input } as unknown as RpcInput);
|
|
198
|
+
|
|
199
|
+
// --- site furniture ---
|
|
200
|
+
listMenus = () => this.call<Menu[]>("listMenus");
|
|
201
|
+
createMenu = (name: string, label: string) => this.call<Menu>("createMenu", { name, label, items: [] });
|
|
202
|
+
updateMenu = (id: string, patch: { label?: string; items?: MenuItem[]; expectedVersion?: number }) => this.call<Menu>("updateMenu", { id, ...patch } as unknown as RpcInput);
|
|
203
|
+
deleteMenu = (id: string) => this.call<{ ok: true }>("deleteMenu", { id });
|
|
204
|
+
|
|
205
|
+
listRedirects = (limit = 200, offset = 0) => this.call<Redirect[]>("listRedirects", { limit, offset });
|
|
206
|
+
createRedirect = (input: { fromPath: string; toPath: string; status?: number; enabled?: boolean; note?: string }) =>
|
|
207
|
+
this.call<Redirect>("createRedirect", input);
|
|
208
|
+
updateRedirect = (id: string, patch: { fromPath?: string; toPath?: string; status?: number; enabled?: boolean; note?: string | null }) =>
|
|
209
|
+
this.call<Redirect>("updateRedirect", { id, ...patch } as unknown as RpcInput);
|
|
210
|
+
deleteRedirect = (id: string) => this.call<{ ok: true }>("deleteRedirect", { id });
|
|
211
|
+
|
|
212
|
+
listTaxonomies = () => this.call<Taxonomy[]>("listTaxonomies");
|
|
213
|
+
createTaxonomy = (input: { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean }) =>
|
|
214
|
+
this.call<Taxonomy>("createTaxonomy", input);
|
|
215
|
+
updateTaxonomy = (id: string, patch: { label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean }) =>
|
|
216
|
+
this.call<Taxonomy>("updateTaxonomy", { id, ...patch } as unknown as RpcInput);
|
|
217
|
+
deleteTaxonomy = (id: string) => this.call<{ ok: true }>("deleteTaxonomy", { id });
|
|
218
|
+
|
|
219
|
+
/** A vocabulary's terms as a TREE. The server folds it, so every consumer does not. */
|
|
220
|
+
getTermTree = (taxonomy: string) => this.call<Term[]>("getTermTree", { taxonomy });
|
|
221
|
+
createTerm = (input: { taxonomy: string; slug: string; label: string; description?: string; parentId?: string | null; position?: number }) =>
|
|
222
|
+
this.call<Term>("createTerm", input as unknown as RpcInput);
|
|
223
|
+
updateTerm = (id: string, patch: { slug?: string; label?: string; description?: string | null; parentId?: string | null; position?: number }) =>
|
|
224
|
+
this.call<Term>("updateTerm", { id, ...patch } as unknown as RpcInput);
|
|
225
|
+
deleteTerm = (id: string) => this.call<{ ok: true }>("deleteTerm", { id });
|
|
226
|
+
|
|
227
|
+
listPageTerms = (pageId: string) => this.call<Term[]>("listPageTerms", { pageId });
|
|
228
|
+
setPageTerms = (pageId: string, termIds: string[]) => this.call<{ ok: true }>("setPageTerms", { pageId, termIds });
|
|
229
|
+
|
|
230
|
+
// --- Block Kit: custom admin pages ---
|
|
231
|
+
/** The pages THIS caller may open. Filtered server-side, so a nav entry that 403s when
|
|
232
|
+
* clicked cannot happen. An older server has no such handler; the caller treats a failure
|
|
233
|
+
* as "no custom pages". */
|
|
234
|
+
listAdminPages = () => this.call<AdminPageMeta[]>("listAdminPages");
|
|
235
|
+
/** Render a page, or act on it and render the result. One round trip per interaction. */
|
|
236
|
+
adminPageInteract = (input: { page: string; type: "page_load" | "block_action" | "form_submit"; action_id?: string; block_id?: string; value?: unknown; values?: Record<string, unknown> }) =>
|
|
237
|
+
this.call<AdminPageResponse>("adminPageInteract", input as unknown as RpcInput);
|
|
238
|
+
|
|
239
|
+
listWidgetAreas = () => this.call<WidgetArea[]>("listWidgetAreas");
|
|
240
|
+
createWidgetArea = (name: string, label: string, description?: string) => this.call<WidgetArea>("createWidgetArea", { name, label, description, widgets: [] });
|
|
241
|
+
updateWidgetArea = (id: string, patch: { label?: string; description?: string | null; widgets?: Widget[]; expectedVersion?: number }) =>
|
|
242
|
+
this.call<WidgetArea>("updateWidgetArea", { id, ...patch } as unknown as RpcInput);
|
|
243
|
+
deleteWidgetArea = (id: string) => this.call<{ ok: true }>("deleteWidgetArea", { id });
|
|
244
|
+
|
|
167
245
|
/** Full upload flow: sign → PUT the bytes → persist a `cms_media` row. Returns the row. */
|
|
168
246
|
async uploadMedia(file: File): Promise<Media> {
|
|
169
247
|
const contentType = file.type || "application/octet-stream";
|
package/src/app-context.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { createContext, use, useCallback, useEffect, useMemo, useRef, useState }
|
|
|
8
8
|
import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
|
|
9
9
|
import { BRAND, SETUP_TITLE, type BrandConfig } from "./brand";
|
|
10
10
|
import { readBackend, type BackendHost } from "./mount";
|
|
11
|
-
import { DEFAULT_CAPABILITIES, type CmsCapabilities, type CollectionMeta, type ContentType, type JsonValue } from "./types";
|
|
11
|
+
import { DEFAULT_CAPABILITIES, type AdminPageMeta, type CmsCapabilities, type CollectionMeta, type ContentType, type JsonValue } from "./types";
|
|
12
12
|
|
|
13
13
|
declare global {
|
|
14
14
|
interface Window {
|
|
@@ -26,8 +26,9 @@ declare global {
|
|
|
26
26
|
* reads as "the CMS is broken" rather than "this site has no pages". */
|
|
27
27
|
hidePages?: boolean;
|
|
28
28
|
/** Extra top-nav links to companion tools the host serves (e.g. a curation page).
|
|
29
|
-
* Rendered as plain external `<a>` links
|
|
30
|
-
|
|
29
|
+
* Rendered as plain external `<a>` links, positioned by `order` (see `NAV_ORDER`) and
|
|
30
|
+
* defaulting to after the built-in tabs. */
|
|
31
|
+
extraNav?: { label: string; href: string; target?: "_blank" | "_self"; order?: number }[];
|
|
31
32
|
/** The wordmark in the topbar, on the Setup screen, and in the browser tab.
|
|
32
33
|
*
|
|
33
34
|
* This editor ships as a package an agency deploys FOR ITS CLIENT, so the default
|
|
@@ -93,6 +94,14 @@ interface AppContextValue {
|
|
|
93
94
|
/** Collections registered on the server (from `listCollections`) — drives the nav + the
|
|
94
95
|
* generic list/edit routes. Empty when the server registers none. */
|
|
95
96
|
collections: CollectionMeta[];
|
|
97
|
+
/** Custom admin pages the CALLER may open (from `listAdminPages`) — Block Kit screens a
|
|
98
|
+
* project registered with `adminPage()`. They render inside the editor's own chrome, at a
|
|
99
|
+
* nav position they choose, which is the difference from an `extraNav` link.
|
|
100
|
+
*
|
|
101
|
+
* Filtered server-side by role, so there is no entry here the caller cannot open. An app
|
|
102
|
+
* that registers none (or a server without the handler) leaves this empty; a failure is
|
|
103
|
+
* non-fatal, exactly as with collections. */
|
|
104
|
+
adminPages: AdminPageMeta[];
|
|
96
105
|
/** Content types registered on the server (from `listContentTypes`). More than one ⇒ each
|
|
97
106
|
* gets its own nav tab and its own list route, instead of one pooled "Pages" list where a
|
|
98
107
|
* page and an article sit in the same column with nothing to tell them apart.
|
|
@@ -130,6 +139,31 @@ interface AppContextValue {
|
|
|
130
139
|
|
|
131
140
|
const AppContext = createContext<AppContextValue | null>(null);
|
|
132
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Register this screen's unsaved-changes guard for as long as `dirty` is true.
|
|
144
|
+
*
|
|
145
|
+
* The five authoring screens added alongside the CMS work each hold a whole unsaved
|
|
146
|
+
* document in local state — a field schema, a menu tree, a widget list — and none of them
|
|
147
|
+
* registered a guard, so any topbar click discarded the work with no prompt. `PageEditor`
|
|
148
|
+
* had one; nothing made that reusable, so it stayed the only screen with one.
|
|
149
|
+
*
|
|
150
|
+
* `beforeunload` too, for the refresh/close half — in-app navigation fires neither, which
|
|
151
|
+
* is why both halves are needed and why the context guard exists at all.
|
|
152
|
+
*/
|
|
153
|
+
export function useUnsavedGuard(dirty: boolean, message = "You have unsaved changes. Leave anyway?"): void {
|
|
154
|
+
const { setNavGuard } = useApp();
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!dirty) return;
|
|
157
|
+
setNavGuard(() => confirm(message));
|
|
158
|
+
const onUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); };
|
|
159
|
+
window.addEventListener("beforeunload", onUnload);
|
|
160
|
+
return () => {
|
|
161
|
+
setNavGuard(null);
|
|
162
|
+
window.removeEventListener("beforeunload", onUnload);
|
|
163
|
+
};
|
|
164
|
+
}, [dirty, message, setNavGuard]);
|
|
165
|
+
}
|
|
166
|
+
|
|
133
167
|
export function useApp(): AppContextValue {
|
|
134
168
|
const v = use(AppContext);
|
|
135
169
|
if (!v) throw new Error("useApp must be used within <AppProvider>");
|
|
@@ -140,6 +174,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
140
174
|
const [cfg, setCfg] = useState<Config>(() => loadConfig(BACKEND));
|
|
141
175
|
const [me, setMe] = useState<Me | null>(null);
|
|
142
176
|
const [collections, setCollections] = useState<CollectionMeta[]>([]);
|
|
177
|
+
const [adminPages, setAdminPages] = useState<AdminPageMeta[]>([]);
|
|
143
178
|
const [contentTypes, setContentTypes] = useState<ContentType[] | null>(null);
|
|
144
179
|
const [contentTypesFailed, setContentTypesFailed] = useState(false);
|
|
145
180
|
const [contentTypesNonce, setContentTypesNonce] = useState(0);
|
|
@@ -201,6 +236,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
201
236
|
// Collections drive the nav + list/edit routes. An app that registers none (or an older
|
|
202
237
|
// server without the handler) just leaves the nav as-is — a failure is non-fatal.
|
|
203
238
|
api.call<CollectionMeta[]>("listCollections").then(setCollections).catch(() => setCollections([]));
|
|
239
|
+
// Same shape, same tolerance: an app that registers no Block Kit pages, or a server
|
|
240
|
+
// without the handler, just leaves the nav as it was.
|
|
241
|
+
api.listAdminPages().then(setAdminPages).catch(() => setAdminPages([]));
|
|
204
242
|
// A server older than this handler leaves the monolingual default, which is the safe
|
|
205
243
|
// way round: the i18n surface stays hidden rather than half-rendered. Merged OVER the
|
|
206
244
|
// defaults, not substituted for them, so a capability the server does not know about
|
|
@@ -238,6 +276,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
238
276
|
me,
|
|
239
277
|
isAdmin: (me?.roles ?? []).includes("admin"),
|
|
240
278
|
collections,
|
|
279
|
+
adminPages,
|
|
241
280
|
contentTypes,
|
|
242
281
|
contentTypesFailed,
|
|
243
282
|
refreshContentTypes,
|
package/src/blockkit.tsx
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// Block Kit — rendering a custom admin page the server described as JSON (GitHub #33).
|
|
2
|
+
//
|
|
3
|
+
// The loop is one round trip per interaction: the editor sends `page_load`, the server
|
|
4
|
+
// answers with blocks, the host renders them, a click or a submit goes back, new blocks come
|
|
5
|
+
// out. No project JavaScript ever runs here — a block is DATA, and every string in it goes
|
|
6
|
+
// through React, so there is no markup path to sanitize on this side. The one attribute that
|
|
7
|
+
// is not text (`image.url`) is allow-listed server-side on the way out.
|
|
8
|
+
//
|
|
9
|
+
// This is the generalization of what `FieldForm` already was. `FieldDefinition[]` is
|
|
10
|
+
// "server-described form, host-rendered"; a Block Kit page is the same idea with layout and
|
|
11
|
+
// display blocks alongside the inputs, so a project gets a whole SCREEN inside the admin
|
|
12
|
+
// chrome instead of a link that opens a second app in a new tab.
|
|
13
|
+
//
|
|
14
|
+
// The WHOLE page comes back on every interaction. There is no patch protocol on purpose: a
|
|
15
|
+
// server that returned only what changed would have to agree with the host about what is
|
|
16
|
+
// currently on screen, and the two drift the first time a render depends on data that moved.
|
|
17
|
+
|
|
18
|
+
import { Button, Heading } from "@podoba/react";
|
|
19
|
+
import { useCallback, useEffect, useState } from "react";
|
|
20
|
+
import type { Api } from "./api";
|
|
21
|
+
import { CONTROL } from "./fields";
|
|
22
|
+
import { WRAP } from "./chrome";
|
|
23
|
+
import type { AdminBlock, AdminElement, AdminInput, AdminPageResponse, JsonValue } from "./types";
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/** Values held for the inputs of one block, keyed by `action_id`. */
|
|
27
|
+
type BlockValues = Record<string, JsonValue>;
|
|
28
|
+
|
|
29
|
+
/** What an interaction hands back to the page: which control fired, and with what. */
|
|
30
|
+
interface Fired {
|
|
31
|
+
type: "block_action" | "form_submit";
|
|
32
|
+
action_id: string;
|
|
33
|
+
block_id?: string;
|
|
34
|
+
value?: JsonValue;
|
|
35
|
+
values?: BlockValues;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function AdminPageView({ api, slug, label, onError }: { api: Api; slug: string; label: string; onError: (s: string) => void }) {
|
|
39
|
+
const [res, setRes] = useState<AdminPageResponse | null>(null);
|
|
40
|
+
/**
|
|
41
|
+
* Every input on the page, keyed by `action_id`, held HERE rather than per block.
|
|
42
|
+
*
|
|
43
|
+
* A block's inputs used to be local to it, so an interaction carried only the pressed
|
|
44
|
+
* block's own values — and the shipped `lecture-desk` example is built the way any such
|
|
45
|
+
* page is: a search box in one `actions` block, per-row buttons in another. Pressing a
|
|
46
|
+
* row button sent `values: {}`, the page recomputed its filter as empty, and the table
|
|
47
|
+
* came back UNFILTERED while the search box still showed the term. The three row buttons
|
|
48
|
+
* then addressed three different records than the ones on screen when the user confirmed
|
|
49
|
+
* a destructive action.
|
|
50
|
+
*
|
|
51
|
+
* `action_id` is unique per page by contract (it is what `render` switches on), so one
|
|
52
|
+
* map is the shape the server already assumes. Re-seeded from every response, because the
|
|
53
|
+
* server re-renders the whole page and its `initial_value`s are the authority.
|
|
54
|
+
*/
|
|
55
|
+
const [values, setValues] = useState<BlockValues>({});
|
|
56
|
+
const [busy, setBusy] = useState(false);
|
|
57
|
+
const [failed, setFailed] = useState(false);
|
|
58
|
+
const [toast, setToast] = useState<AdminPageResponse["toast"] | null>(null);
|
|
59
|
+
|
|
60
|
+
const send = useCallback(
|
|
61
|
+
async (fired?: Fired) => {
|
|
62
|
+
setBusy(true);
|
|
63
|
+
setFailed(false);
|
|
64
|
+
try {
|
|
65
|
+
const next = await api.adminPageInteract({ page: slug, type: fired?.type ?? "page_load", ...(fired ?? {}) });
|
|
66
|
+
setRes(next);
|
|
67
|
+
setValues(seedValues(next.blocks));
|
|
68
|
+
setToast(next.toast ?? null);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// A failed LOAD leaves nothing to render, so it says so here. A failed action leaves
|
|
71
|
+
// the previous page on screen, which is the right place to be — the banner names
|
|
72
|
+
// what went wrong and nothing was lost.
|
|
73
|
+
setFailed(true);
|
|
74
|
+
onError(String((e as Error).message ?? e));
|
|
75
|
+
} finally {
|
|
76
|
+
setBusy(false);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
[api, slug, onError],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
useEffect(() => { void send(); }, [send]);
|
|
83
|
+
|
|
84
|
+
// Toasts are transient by definition. Cleared on a timer rather than on the next
|
|
85
|
+
// interaction so a page that ends in a save does not leave "Saved" sitting there until
|
|
86
|
+
// someone clicks something else.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!toast) return;
|
|
89
|
+
const id = setTimeout(() => setToast(null), 3000);
|
|
90
|
+
return () => clearTimeout(id);
|
|
91
|
+
}, [toast]);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className={WRAP}>
|
|
95
|
+
<div className="mb-6 mt-6 flex items-center gap-3">
|
|
96
|
+
<h1 className="m-0 text-[32px] font-normal leading-[1.1] tracking-[-0.01em] text-fg">{label}</h1>
|
|
97
|
+
{busy ? <span className="text-caption text-fg-subtle">working…</span> : null}
|
|
98
|
+
</div>
|
|
99
|
+
{toast ? (
|
|
100
|
+
<div className={`mb-4 rounded-lg border px-3.5 py-2.5 text-small ${toast.tone === "error" ? "border-danger bg-surface-card text-danger" : toast.tone === "success" ? "border-brand-green bg-brand-green/20 text-fg" : "border-border bg-surface-card text-fg-muted"}`}>
|
|
101
|
+
{toast.text}
|
|
102
|
+
</div>
|
|
103
|
+
) : null}
|
|
104
|
+
{res === null ? (
|
|
105
|
+
<p className="text-fg-subtle">{failed ? "This screen could not be loaded." : "Loading…"}</p>
|
|
106
|
+
) : (
|
|
107
|
+
<BlockList
|
|
108
|
+
blocks={res.blocks}
|
|
109
|
+
values={values}
|
|
110
|
+
setValue={(id, v) => setValues((s) => ({ ...s, [id]: v }))}
|
|
111
|
+
disabled={busy}
|
|
112
|
+
// The WHOLE page's inputs ride on every interaction, which is what makes a filter
|
|
113
|
+
// in one block reach a button in another.
|
|
114
|
+
onFire={(f) => void send({ ...f, values })}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ValueBag {
|
|
122
|
+
values: BlockValues;
|
|
123
|
+
setValue: (actionId: string, v: JsonValue) => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function BlockList({ blocks, values, setValue, disabled, onFire }: { blocks: AdminBlock[]; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
127
|
+
return (
|
|
128
|
+
<div className="flex flex-col gap-4">
|
|
129
|
+
{/* Keyed by the block's OWN identity where it has one, not by index. `FormBlock`'s
|
|
130
|
+
comment claims a `block_id` key is what re-seeds its inputs from the server's
|
|
131
|
+
fresh `initial_value`s — and it was right about the requirement and wrong about
|
|
132
|
+
the code, because this line keyed on `i`. A wizard whose step 2 put a different
|
|
133
|
+
form at the same index kept step 1's `values`: every field rendered empty while
|
|
134
|
+
`missing` blocked submit forever, and a typed `secret_input` survived into a later
|
|
135
|
+
interaction. Blocks without an id keep the index; they hold no state. */}
|
|
136
|
+
{blocks.map((block, i) => <BlockView key={blockKey(block, i)} block={block} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />)}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A stable React key for a block. `form` carries a required `block_id`; `actions` may.
|
|
142
|
+
* Prefixed so a block_id can never collide with a bare index from a sibling. */
|
|
143
|
+
function blockKey(block: AdminBlock, i: number): string {
|
|
144
|
+
const id = block.type === "form" ? block.block_id : block.type === "actions" ? block.block_id : undefined;
|
|
145
|
+
return id ? `id:${id}` : `${block.type}:${i}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function BlockView({ block, values, setValue, disabled, onFire }: { block: AdminBlock; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
149
|
+
switch (block.type) {
|
|
150
|
+
case "header":
|
|
151
|
+
return <Heading level={block.level === 3 ? "3" : block.level === 2 ? "2" : "1"} className="font-normal">{block.text}</Heading>;
|
|
152
|
+
case "section":
|
|
153
|
+
// `whitespace-pre-wrap` so a page can lay out a paragraph with line breaks without
|
|
154
|
+
// needing markup — which is the thing this format deliberately does not have.
|
|
155
|
+
return <p className="max-w-[72ch] whitespace-pre-wrap text-sm text-fg">{block.text}</p>;
|
|
156
|
+
case "context":
|
|
157
|
+
return <p className="max-w-[72ch] text-caption text-fg-subtle">{block.text}</p>;
|
|
158
|
+
case "divider":
|
|
159
|
+
return <hr className="border-t border-border" />;
|
|
160
|
+
case "empty":
|
|
161
|
+
return (
|
|
162
|
+
<div className="rounded-lg border border-dashed border-border px-5 py-8 text-center">
|
|
163
|
+
<p className="text-sm text-fg-muted">{block.text}</p>
|
|
164
|
+
{block.hint ? <p className="mt-1 text-caption text-fg-subtle">{block.hint}</p> : null}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
case "fields":
|
|
168
|
+
return (
|
|
169
|
+
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-[13px]">
|
|
170
|
+
{block.fields.map((f, i) => (
|
|
171
|
+
<div key={i} className="contents">
|
|
172
|
+
<span className="text-fg-subtle">{f.label}</span>
|
|
173
|
+
<span className="text-fg">{f.value}</span>
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
case "stats":
|
|
179
|
+
return (
|
|
180
|
+
<div className="flex flex-wrap gap-3">
|
|
181
|
+
{block.stats.map((s, i) => (
|
|
182
|
+
<div key={i} className="min-w-[140px] flex-1 rounded-lg border border-border bg-surface-card p-4">
|
|
183
|
+
<div className="text-caption text-fg-subtle">{s.label}</div>
|
|
184
|
+
<div className="mt-1 text-[24px] leading-none text-fg">{s.value}</div>
|
|
185
|
+
{s.hint ? <div className="mt-1 text-caption text-fg-subtle">{s.hint}</div> : null}
|
|
186
|
+
</div>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
case "table":
|
|
191
|
+
return <TableBlock block={block} />;
|
|
192
|
+
case "image":
|
|
193
|
+
return (
|
|
194
|
+
<figure className="m-0">
|
|
195
|
+
<img src={block.url} alt={block.alt ?? ""} className="max-w-full rounded-lg" />
|
|
196
|
+
{block.caption ? <figcaption className="mt-1 text-caption text-fg-subtle">{block.caption}</figcaption> : null}
|
|
197
|
+
</figure>
|
|
198
|
+
);
|
|
199
|
+
case "columns":
|
|
200
|
+
return (
|
|
201
|
+
<div className="grid gap-4 max-[820px]:grid-cols-1" style={{ gridTemplateColumns: `repeat(${block.columns.length}, minmax(0, 1fr))` }}>
|
|
202
|
+
{block.columns.map((col, i) => <BlockList key={i} blocks={col} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
case "accordion":
|
|
206
|
+
return (
|
|
207
|
+
<details className="rounded-lg border border-border bg-surface-card px-4 py-3" open={block.open}>
|
|
208
|
+
<summary className="cursor-pointer text-sm font-medium text-fg">{block.title}</summary>
|
|
209
|
+
<div className="mt-3">
|
|
210
|
+
<BlockList blocks={block.blocks} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />
|
|
211
|
+
</div>
|
|
212
|
+
</details>
|
|
213
|
+
);
|
|
214
|
+
case "actions":
|
|
215
|
+
return <ActionsBlock block={block} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />;
|
|
216
|
+
case "form":
|
|
217
|
+
return <FormBlock block={block} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />;
|
|
218
|
+
default:
|
|
219
|
+
// An unknown block type comes from a server newer than this editor. Named rather than
|
|
220
|
+
// skipped: a page whose one meaningful block silently vanished looks like missing data.
|
|
221
|
+
return <p className="text-caption text-fg-subtle">[unsupported block: {(block as { type: string }).type}]</p>;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function TableBlock({ block }: { block: Extract<AdminBlock, { type: "table" }> }) {
|
|
226
|
+
if (block.rows.length === 0) return <p className="text-sm text-fg-subtle">{block.empty ?? "Nothing here."}</p>;
|
|
227
|
+
return (
|
|
228
|
+
// Wide tables scroll INSIDE their own container; the page must not scroll sideways.
|
|
229
|
+
<div className="overflow-x-auto rounded-lg border border-border bg-surface-card">
|
|
230
|
+
<table className="w-full border-collapse text-[13px]">
|
|
231
|
+
<thead>
|
|
232
|
+
<tr>
|
|
233
|
+
{block.columns.map((c) => (
|
|
234
|
+
<th key={c.key} className="border-b border-border px-3 py-2 text-left font-medium text-fg-subtle">{c.label}</th>
|
|
235
|
+
))}
|
|
236
|
+
</tr>
|
|
237
|
+
</thead>
|
|
238
|
+
<tbody>
|
|
239
|
+
{block.rows.map((row, i) => (
|
|
240
|
+
<tr key={i}>
|
|
241
|
+
{block.columns.map((c) => (
|
|
242
|
+
<td key={c.key} className="border-b border-border px-3 py-2 text-fg">{cell(row[c.key])}</td>
|
|
243
|
+
))}
|
|
244
|
+
</tr>
|
|
245
|
+
))}
|
|
246
|
+
</tbody>
|
|
247
|
+
</table>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cell(v: string | number | boolean | null | undefined): string {
|
|
253
|
+
if (v === null || v === undefined) return "";
|
|
254
|
+
if (typeof v === "boolean") return v ? "yes" : "no";
|
|
255
|
+
return String(v);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function ActionsBlock({ block, values, setValue, disabled, onFire }: { block: Extract<AdminBlock, { type: "actions" }>; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
259
|
+
// Inputs read and write the PAGE's value bag, not a local one — see `AdminPageView`.
|
|
260
|
+
// `onFire` attaches the whole bag, so a filter in this block reaches a button in another.
|
|
261
|
+
return (
|
|
262
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
263
|
+
{block.elements.map((el, i) =>
|
|
264
|
+
el.type === "button" ? (
|
|
265
|
+
<Button
|
|
266
|
+
key={i}
|
|
267
|
+
variant={el.style === "primary" ? "primary" : el.style === "danger" ? "ghost" : "secondary"}
|
|
268
|
+
className={el.style === "danger" ? "text-danger" : undefined}
|
|
269
|
+
isDisabled={disabled}
|
|
270
|
+
onPress={() => {
|
|
271
|
+
if (el.confirm && !confirm(el.confirm)) return;
|
|
272
|
+
onFire({ type: "block_action", action_id: el.action_id, block_id: block.block_id, value: el.value ?? null });
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
{el.label}
|
|
276
|
+
</Button>
|
|
277
|
+
) : (
|
|
278
|
+
<InputView key={el.action_id} input={el} value={values[el.action_id]} onChange={(v) => setValue(el.action_id, v)} disabled={disabled} />
|
|
279
|
+
),
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function FormBlock({ block, values, setValue, disabled, onFire }: { block: Extract<AdminBlock, { type: "form" }>; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
286
|
+
// No local state: the page owns the bag and re-seeds it from every response, so a form
|
|
287
|
+
// cannot keep values the server has since replaced. (The `block_id` key in `BlockList` is
|
|
288
|
+
// still what keeps two forms from sharing a React identity.)
|
|
289
|
+
//
|
|
290
|
+
// A toggle is never "missing" — false is an answer.
|
|
291
|
+
const missing = block.fields.filter((f) => f.type !== "toggle" && f.required && isEmpty(values[f.action_id]));
|
|
292
|
+
return (
|
|
293
|
+
<form
|
|
294
|
+
className="flex max-w-[720px] flex-col gap-3 rounded-lg border border-border bg-surface-card p-5"
|
|
295
|
+
onSubmit={(e) => {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
if (missing.length > 0 || disabled) return;
|
|
298
|
+
onFire({ type: "form_submit", action_id: block.submit.action_id, block_id: block.block_id });
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{block.fields.map((f) => (
|
|
302
|
+
<InputView key={f.action_id} input={f} value={values[f.action_id]} onChange={(v) => setValue(f.action_id, v)} disabled={disabled} />
|
|
303
|
+
))}
|
|
304
|
+
<div className="mt-1">
|
|
305
|
+
<Button type="submit" isDisabled={disabled || missing.length > 0}>{block.submit.label}</Button>
|
|
306
|
+
{missing.length > 0 ? (
|
|
307
|
+
<span className="ml-3 text-caption text-fg-subtle">Fill in: {missing.map((f) => f.label ?? f.action_id).join(", ")}</span>
|
|
308
|
+
) : null}
|
|
309
|
+
</div>
|
|
310
|
+
</form>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const isInput = (el: AdminElement): el is AdminInput => el.type !== "button";
|
|
315
|
+
|
|
316
|
+
/** Every input on a page, in declaration order, including those nested in `columns` and
|
|
317
|
+
* `accordion`. The page seeds its whole value bag from this on every response. */
|
|
318
|
+
function collectInputs(blocks: readonly AdminBlock[], out: AdminInput[] = []): AdminInput[] {
|
|
319
|
+
for (const b of blocks) {
|
|
320
|
+
if (b.type === "form") out.push(...b.fields);
|
|
321
|
+
else if (b.type === "actions") out.push(...b.elements.filter(isInput));
|
|
322
|
+
else if (b.type === "columns") for (const col of b.columns) collectInputs(col, out);
|
|
323
|
+
else if (b.type === "accordion") collectInputs(b.blocks, out);
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The page's value bag as a fresh response describes it. */
|
|
329
|
+
function seedValues(blocks: readonly AdminBlock[]): BlockValues {
|
|
330
|
+
return initialValues(collectInputs(blocks));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function initialValues(inputs: readonly AdminInput[]): BlockValues {
|
|
334
|
+
const out: BlockValues = {};
|
|
335
|
+
for (const f of inputs) {
|
|
336
|
+
// A `secret_input` deliberately has no `initial_value` — a stored secret is never echoed
|
|
337
|
+
// back to the browser, so the field starts empty on every render.
|
|
338
|
+
if (f.type === "toggle") out[f.action_id] = f.initial_value ?? false;
|
|
339
|
+
else if (f.type === "number_input") out[f.action_id] = f.initial_value ?? null;
|
|
340
|
+
else if (f.type === "secret_input") out[f.action_id] = "";
|
|
341
|
+
else out[f.action_id] = f.initial_value ?? "";
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const isEmpty = (v: JsonValue | undefined): boolean => v === undefined || v === null || v === "";
|
|
347
|
+
|
|
348
|
+
function InputView({ input, value, onChange, disabled }: { input: AdminInput; value: JsonValue | undefined; onChange: (v: JsonValue) => void; disabled: boolean }) {
|
|
349
|
+
const label = input.label ? (
|
|
350
|
+
<span className="text-caption text-fg-subtle">
|
|
351
|
+
{input.label}
|
|
352
|
+
{input.type !== "toggle" && input.required ? <span className="text-danger"> *</span> : null}
|
|
353
|
+
</span>
|
|
354
|
+
) : null;
|
|
355
|
+
|
|
356
|
+
if (input.type === "toggle") {
|
|
357
|
+
return (
|
|
358
|
+
<label className="flex items-center gap-2">
|
|
359
|
+
<input type="checkbox" checked={value === true} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
|
|
360
|
+
<span className="text-sm text-fg">{input.label ?? input.action_id}</span>
|
|
361
|
+
</label>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<label className="flex min-w-[180px] flex-col gap-1.5">
|
|
367
|
+
{label}
|
|
368
|
+
{input.type === "select" ? (
|
|
369
|
+
<select className={CONTROL} value={typeof value === "string" ? value : ""} disabled={disabled} aria-label={input.label ?? input.action_id} onChange={(e) => onChange(e.target.value)}>
|
|
370
|
+
<option value="">—</option>
|
|
371
|
+
{input.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
372
|
+
</select>
|
|
373
|
+
) : input.type === "number_input" ? (
|
|
374
|
+
<input
|
|
375
|
+
className={CONTROL}
|
|
376
|
+
type="number"
|
|
377
|
+
min={input.min}
|
|
378
|
+
max={input.max}
|
|
379
|
+
placeholder={input.placeholder}
|
|
380
|
+
disabled={disabled}
|
|
381
|
+
aria-label={input.label ?? input.action_id}
|
|
382
|
+
value={typeof value === "number" ? String(value) : ""}
|
|
383
|
+
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
|
|
384
|
+
/>
|
|
385
|
+
) : input.type === "text_input" && input.multiline ? (
|
|
386
|
+
<textarea
|
|
387
|
+
className={`${CONTROL} h-auto min-h-24 py-2.5`}
|
|
388
|
+
placeholder={input.placeholder}
|
|
389
|
+
disabled={disabled}
|
|
390
|
+
aria-label={input.label ?? input.action_id}
|
|
391
|
+
value={typeof value === "string" ? value : ""}
|
|
392
|
+
onChange={(e) => onChange(e.target.value)}
|
|
393
|
+
/>
|
|
394
|
+
) : (
|
|
395
|
+
<input
|
|
396
|
+
className={CONTROL}
|
|
397
|
+
// `secret_input` is a password field AND is never seeded from the server, so a
|
|
398
|
+
// stored secret cannot be read back out of the admin's DOM.
|
|
399
|
+
type={input.type === "secret_input" ? "password" : "text"}
|
|
400
|
+
autoComplete={input.type === "secret_input" ? "new-password" : undefined}
|
|
401
|
+
placeholder={input.placeholder}
|
|
402
|
+
disabled={disabled}
|
|
403
|
+
aria-label={input.label ?? input.action_id}
|
|
404
|
+
value={typeof value === "string" ? value : ""}
|
|
405
|
+
onChange={(e) => onChange(e.target.value)}
|
|
406
|
+
/>
|
|
407
|
+
)}
|
|
408
|
+
</label>
|
|
409
|
+
);
|
|
410
|
+
}
|