@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.59",
3
+ "version": "0.0.60",
4
4
  "description": "Visual block/page editor for @pramen/cms \u2014 a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
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";
@@ -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 after the built-in tabs. */
30
- extraNav?: { label: string; href: string; target?: "_blank" | "_self" }[];
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,
@@ -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
+ }