@pramen/cms-editor 0.0.61 → 0.0.64
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 +70 -4
- package/dist/editor.css +1 -1
- package/dist/editor.js +155 -155
- package/dist/panel-jsx-dev-runtime.js +8 -0
- package/dist/panel-jsx-runtime.js +8 -0
- package/dist/panel-react-dom.js +8 -0
- package/dist/panel-react.js +8 -0
- package/package.json +8 -3
- package/src/api.ts +23 -6
- package/src/app-context.tsx +13 -3
- package/src/app.css +48 -0
- package/src/blockkit.tsx +109 -32
- package/src/breadcrumb.tsx +48 -0
- package/src/chrome.ts +27 -0
- package/src/components.tsx +624 -183
- package/src/cover.tsx +163 -0
- package/src/furniture.tsx +121 -27
- package/src/icons.tsx +97 -0
- package/src/main.tsx +18 -0
- package/src/nav.ts +149 -15
- package/src/page-header.tsx +128 -0
- package/src/panel-boundary.tsx +67 -0
- package/src/panel-globals.ts +103 -0
- package/src/panel-runtime.ts +105 -0
- package/src/panels.ts +420 -0
- package/src/preview.ts +65 -0
- package/src/routes/_layout.tsx +515 -99
- package/src/routes/admin-page.tsx +77 -3
- package/src/routes/page.tsx +4 -0
- package/src/theme.ts +80 -0
- package/src/types.ts +103 -7
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
2
|
+
// Resolves the bare specifier "react/jsx-dev-runtime" for a panel bundle, against the React the
|
|
3
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
4
|
+
const ns = globalThis.PRAMEN_CMS_EDITOR_RUNTIME?.jsxDevRuntime;
|
|
5
|
+
if (!ns) throw new Error("pramen/cms-editor: no editor runtime on this page — a panel bundle was loaded outside the CMS editor, or before it booted.");
|
|
6
|
+
const m = ns.default ?? ns;
|
|
7
|
+
export default m;
|
|
8
|
+
export const { Fragment, jsxDEV } = m;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
2
|
+
// Resolves the bare specifier "react/jsx-runtime" for a panel bundle, against the React the
|
|
3
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
4
|
+
const ns = globalThis.PRAMEN_CMS_EDITOR_RUNTIME?.jsxRuntime;
|
|
5
|
+
if (!ns) throw new Error("pramen/cms-editor: no editor runtime on this page — a panel bundle was loaded outside the CMS editor, or before it booted.");
|
|
6
|
+
const m = ns.default ?? ns;
|
|
7
|
+
export default m;
|
|
8
|
+
export const { Fragment, jsx, jsxs } = m;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
2
|
+
// Resolves the bare specifier "react-dom" for a panel bundle, against the React the
|
|
3
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
4
|
+
const ns = globalThis.PRAMEN_CMS_EDITOR_RUNTIME?.reactDom;
|
|
5
|
+
if (!ns) throw new Error("pramen/cms-editor: no editor runtime on this page — a panel bundle was loaded outside the CMS editor, or before it booted.");
|
|
6
|
+
const m = ns.default ?? ns;
|
|
7
|
+
export default m;
|
|
8
|
+
export const { createPortal, flushSync, preconnect, prefetchDNS, preinit, preinitModule, preload, preloadModule, requestFormReset, unstable_batchedUpdates, useFormState, useFormStatus, version } = m;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
2
|
+
// Resolves the bare specifier "react" for a panel bundle, against the React the
|
|
3
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
4
|
+
const ns = globalThis.PRAMEN_CMS_EDITOR_RUNTIME?.react;
|
|
5
|
+
if (!ns) throw new Error("pramen/cms-editor: no editor runtime on this page — a panel bundle was loaded outside the CMS editor, or before it booted.");
|
|
6
|
+
const m = ns.default ?? ns;
|
|
7
|
+
export default m;
|
|
8
|
+
export const { Activity, Children, Component, Fragment, Profiler, PureComponent, StrictMode, Suspense, act, cache, cacheSignal, captureOwnerStack, cloneElement, createContext, createElement, createRef, forwardRef, isValidElement, lazy, memo, startTransition, unstable_useCacheRefresh, use, useActionState, useCallback, useContext, useDebugValue, useDeferredValue, useEffect, useEffectEvent, useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, useMemo, useOptimistic, useReducer, useRef, useState, useSyncExternalStore, useTransition, version } = m;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms-editor",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Visual block/page editor for @pramen/cms
|
|
3
|
+
"version": "0.0.64",
|
|
4
|
+
"description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
"type": "module",
|
|
14
14
|
"exports": {
|
|
15
15
|
"./editor.js": "./dist/editor.js",
|
|
16
|
-
"./editor.css": "./dist/editor.css"
|
|
16
|
+
"./editor.css": "./dist/editor.css",
|
|
17
|
+
"./panel-react.js": "./dist/panel-react.js",
|
|
18
|
+
"./panel-react-dom.js": "./dist/panel-react-dom.js",
|
|
19
|
+
"./panel-jsx-runtime.js": "./dist/panel-jsx-runtime.js",
|
|
20
|
+
"./panel-jsx-dev-runtime.js": "./dist/panel-jsx-dev-runtime.js"
|
|
17
21
|
},
|
|
18
22
|
"files": [
|
|
19
23
|
"dist",
|
|
@@ -27,6 +31,7 @@
|
|
|
27
31
|
},
|
|
28
32
|
"dependencies": {
|
|
29
33
|
"@buzola/router": "^0.0.12",
|
|
34
|
+
"@phosphor-icons/react": "^2.1.10",
|
|
30
35
|
"@podoba/react": "^0.0.34",
|
|
31
36
|
"@podoba/tailwind": "^0.0.34",
|
|
32
37
|
"@podoba/tokens": "^0.0.34",
|
package/src/api.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// same transport shape as @pramen/admin's api.ts. Config is persisted in localStorage.
|
|
3
3
|
|
|
4
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";
|
|
5
|
+
import type { DefaultBlockDefinition, FieldDefinition, MediaKind, MediaSort, RegionDefinition, RpcInput, TaxonomyTarget } from "./types";
|
|
6
6
|
|
|
7
7
|
/** The payload `createBlockType` / `updateBlockType` take. `fieldsSchema` is the whole
|
|
8
8
|
* point: a block type IS its field schema. */
|
|
@@ -173,7 +173,13 @@ export class Api {
|
|
|
173
173
|
listPageAudit = (pageId: string) => this.call<AuditEntry[]>("listPageAudit", { pageId });
|
|
174
174
|
|
|
175
175
|
// --- media ---
|
|
176
|
-
|
|
176
|
+
// `undefined` values are simply not serialized, so the absent narrowings need no
|
|
177
|
+
// conditional spreading — an omitted key and a key set to undefined reach the server the same.
|
|
178
|
+
/** Options rather than positionals: the library narrows by four independent things, and
|
|
179
|
+
* `listMedia(60, 0, sort, undefined, undefined, term)` is a call site nobody can read and
|
|
180
|
+
* everybody can get one argument out of step. */
|
|
181
|
+
listMedia = (opts: { limit?: number; offset?: number; sort?: MediaSort; kind?: MediaKind; q?: string; term?: string } = {}) =>
|
|
182
|
+
this.call<Media[]>("listMedia", { limit: opts.limit ?? 50, offset: opts.offset ?? 0, sort: opts.sort, kind: opts.kind, q: opts.q, term: opts.term });
|
|
177
183
|
getMedia = (id: string) => this.call<Media | null>("getMedia", { id });
|
|
178
184
|
updateMedia = (id: string, alt: string | null) => this.call<Media>("updateMedia", { id, alt });
|
|
179
185
|
deleteMedia = (id: string) => this.call<{ ok: true }>("deleteMedia", { id });
|
|
@@ -209,10 +215,14 @@ export class Api {
|
|
|
209
215
|
this.call<Redirect>("updateRedirect", { id, ...patch } as unknown as RpcInput);
|
|
210
216
|
deleteRedirect = (id: string) => this.call<{ ok: true }>("deleteRedirect", { id });
|
|
211
217
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
218
|
+
/** Every vocabulary, or only those that classify `target`. The narrowing is the SERVER's —
|
|
219
|
+
* the page panel, the media panel and the write-side guard read one answer, so they cannot
|
|
220
|
+
* disagree about what a vocabulary applies to. The Taxonomies screen passes nothing: the one
|
|
221
|
+
* place that edits `appliesTo` has to see a vocabulary it has narrowed away. */
|
|
222
|
+
listTaxonomies = (target?: TaxonomyTarget) => this.call<Taxonomy[]>("listTaxonomies", { target });
|
|
223
|
+
createTaxonomy = (input: { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null }) =>
|
|
224
|
+
this.call<Taxonomy>("createTaxonomy", { ...input, appliesTo: input.appliesTo ?? null });
|
|
225
|
+
updateTaxonomy = (id: string, patch: { label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null }) =>
|
|
216
226
|
this.call<Taxonomy>("updateTaxonomy", { id, ...patch } as unknown as RpcInput);
|
|
217
227
|
deleteTaxonomy = (id: string) => this.call<{ ok: true }>("deleteTaxonomy", { id });
|
|
218
228
|
|
|
@@ -224,9 +234,16 @@ export class Api {
|
|
|
224
234
|
this.call<Term>("updateTerm", { id, ...patch } as unknown as RpcInput);
|
|
225
235
|
deleteTerm = (id: string) => this.call<{ ok: true }>("deleteTerm", { id });
|
|
226
236
|
|
|
237
|
+
/** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT; anyone
|
|
238
|
+
* holding the result can redeem it with no account, which is the point. */
|
|
239
|
+
signPagePreview = (pageId: string) => this.call<{ url: string; token: string; expiresAt: number }>("signPagePreview", { pageId });
|
|
240
|
+
|
|
227
241
|
listPageTerms = (pageId: string) => this.call<Term[]>("listPageTerms", { pageId });
|
|
228
242
|
setPageTerms = (pageId: string, termIds: string[]) => this.call<{ ok: true }>("setPageTerms", { pageId, termIds });
|
|
229
243
|
|
|
244
|
+
listMediaTerms = (mediaId: string) => this.call<Term[]>("listMediaTerms", { mediaId });
|
|
245
|
+
setMediaTerms = (mediaId: string, termIds: string[]) => this.call<{ ok: true }>("setMediaTerms", { mediaId, termIds });
|
|
246
|
+
|
|
230
247
|
// --- Block Kit: custom admin pages ---
|
|
231
248
|
/** The pages THIS caller may open. Filtered server-side, so a nav entry that 403s when
|
|
232
249
|
* clicked cannot happen. An older server has no such handler; the caller treats a failure
|
package/src/app-context.tsx
CHANGED
|
@@ -29,6 +29,11 @@ declare global {
|
|
|
29
29
|
* Rendered as plain external `<a>` links, positioned by `order` (see `NAV_ORDER`) and
|
|
30
30
|
* defaulting to after the built-in tabs. */
|
|
31
31
|
extraNav?: { label: string; href: string; target?: "_blank" | "_self"; order?: number }[];
|
|
32
|
+
/** Module URLs of this deployment's PANEL bundles — the project's own React screens,
|
|
33
|
+
* rendered inside the chrome at `/apps/:slug`. Imported by the editor at boot (see
|
|
34
|
+
* `main.tsx`), not by a script tag of the shell's, because the shared React they
|
|
35
|
+
* import has to be published first. */
|
|
36
|
+
panels?: string[];
|
|
32
37
|
/** The wordmark in the topbar, on the Setup screen, and in the browser tab.
|
|
33
38
|
*
|
|
34
39
|
* This editor ships as a package an agency deploys FOR ITS CLIENT, so the default
|
|
@@ -36,6 +41,10 @@ declare global {
|
|
|
36
41
|
* own CMS was greeted by "pramen". Set `name` (and optionally `suffix`) to the
|
|
37
42
|
* deployment's own; `suffix: null` drops the "· cms" half entirely. */
|
|
38
43
|
brand?: BrandConfig;
|
|
44
|
+
/** Where THIS SITE renders a page preview (e.g. `/preview`). The editor appends
|
|
45
|
+
* `?token=…`. Unset, a preview link points at the CMS's own redeem endpoint, which
|
|
46
|
+
* answers with JSON — see `preview.ts`. */
|
|
47
|
+
previewUrl?: string;
|
|
39
48
|
};
|
|
40
49
|
}
|
|
41
50
|
}
|
|
@@ -94,9 +103,10 @@ interface AppContextValue {
|
|
|
94
103
|
/** Collections registered on the server (from `listCollections`) — drives the nav + the
|
|
95
104
|
* generic list/edit routes. Empty when the server registers none. */
|
|
96
105
|
collections: CollectionMeta[];
|
|
97
|
-
/** Custom admin
|
|
98
|
-
* project registered with `adminPage()
|
|
99
|
-
* nav position they choose, which is the
|
|
106
|
+
/** Custom admin screens the CALLER may open (from `listAdminPages`) — Block Kit pages a
|
|
107
|
+
* project registered with `adminPage()`, and panels it registered with `adminPanel()`.
|
|
108
|
+
* They render inside the editor's own chrome, at a nav position they choose, which is the
|
|
109
|
+
* difference from an `extraNav` link.
|
|
100
110
|
*
|
|
101
111
|
* Filtered server-side by role, so there is no entry here the caller cannot open. An app
|
|
102
112
|
* that registers none (or a server without the handler) leaves this empty; a failure is
|
package/src/app.css
CHANGED
|
@@ -10,3 +10,51 @@
|
|
|
10
10
|
* it explicitly (paths are relative to this file). */
|
|
11
11
|
@source "./**/*.{ts,tsx}";
|
|
12
12
|
@source "../node_modules/@podoba/react/src/**/*.{ts,tsx}";
|
|
13
|
+
|
|
14
|
+
/* Buttons look clickable.
|
|
15
|
+
*
|
|
16
|
+
* podoba's `Button` sets no `cursor`, and a real `<button>` gets the default arrow from the
|
|
17
|
+
* UA sheet — so every button in this app has been showing an arrow, which reads as "not
|
|
18
|
+
* interactive" to anyone who has used the web. It went unnoticed while the header's Upload
|
|
19
|
+
* control was a hand-styled `<label>` carrying its own `cursor-pointer`; swapping that for the
|
|
20
|
+
* design system's Button is what surfaced it.
|
|
21
|
+
*
|
|
22
|
+
* In the BASE layer on purpose: Tailwind emits utilities after base, so a component that means
|
|
23
|
+
* something else by its cursor still wins — podoba's own `data-[pending]:cursor-progress` on a
|
|
24
|
+
* submitting button, for one. Scoped away from disabled controls, where an arrow is the
|
|
25
|
+
* correct signal.
|
|
26
|
+
*
|
|
27
|
+
* The right home for this is @podoba/react's Button. Until it lands there, one rule here
|
|
28
|
+
* covers the app rather than every call site remembering. */
|
|
29
|
+
@layer base {
|
|
30
|
+
button:not(:disabled):not([aria-disabled="true"]),
|
|
31
|
+
[role="button"]:not([aria-disabled="true"]),
|
|
32
|
+
label:has(> input[type="file"]) {
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Turn OFF scroll anchoring for the page scroller.
|
|
38
|
+
*
|
|
39
|
+
* This is the fix for a self-referential loop, not a preference. The screen header condenses
|
|
40
|
+
* once the page is scrolled, which removes ~134px of layout ABOVE the viewport — and scroll
|
|
41
|
+
* anchoring exists precisely to compensate for that, so the browser silently subtracts the
|
|
42
|
+
* same 134px from `scrollY` to keep the visible content still. That new scroll position is an
|
|
43
|
+
* input to the condition that condensed the header, so it flips back, restores the 134px, and
|
|
44
|
+
* the browser gives the scroll offset back: the header oscillates while the reader is not
|
|
45
|
+
* touching anything. Hysteresis alone cannot fix it — the compensation is the same size as the
|
|
46
|
+
* change, so the dead band would have to be wider than the collapse itself, which just moves
|
|
47
|
+
* the loop further down the page.
|
|
48
|
+
*
|
|
49
|
+
* What anchoring buys HERE is close to nothing, which is what makes this trade cheap: the
|
|
50
|
+
* lists load in explicit "load more" pages appended BELOW the viewport, and every media cell is
|
|
51
|
+
* a fixed 130px box, so no image settling reflows content above where you are reading.
|
|
52
|
+
*
|
|
53
|
+
* On the scroller, because that is the only place it works — `overflow-anchor: none` on an
|
|
54
|
+
* element merely excludes THAT element from being chosen as the anchor, and the header is not
|
|
55
|
+
* the anchor here; the list below it is. */
|
|
56
|
+
@layer base {
|
|
57
|
+
html {
|
|
58
|
+
overflow-anchor: none;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/blockkit.tsx
CHANGED
|
@@ -20,7 +20,8 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
20
20
|
import type { Api } from "./api";
|
|
21
21
|
import { CONTROL } from "./fields";
|
|
22
22
|
import { WRAP } from "./chrome";
|
|
23
|
-
import
|
|
23
|
+
import { ADMIN_ELEMENT_TYPES } from "./types";
|
|
24
|
+
import type { AdminBlock, AdminCell, AdminElement, AdminInput, AdminPageResponse, JsonValue } from "./types";
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
/** Values held for the inputs of one block, keyed by `action_id`. */
|
|
@@ -138,10 +139,10 @@ export function BlockList({ blocks, values, setValue, disabled, onFire }: { bloc
|
|
|
138
139
|
);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
/** A stable React key for a block. `form` carries a required `block_id`; `actions`
|
|
142
|
-
* Prefixed so a block_id can never collide with a bare index from a sibling. */
|
|
142
|
+
/** A stable React key for a block. `form` carries a required `block_id`; `actions` and
|
|
143
|
+
* `table` may. Prefixed so a block_id can never collide with a bare index from a sibling. */
|
|
143
144
|
function blockKey(block: AdminBlock, i: number): string {
|
|
144
|
-
const id = block.type === "form"
|
|
145
|
+
const id = block.type === "form" || block.type === "actions" || block.type === "table" ? block.block_id : undefined;
|
|
145
146
|
return id ? `id:${id}` : `${block.type}:${i}`;
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -188,7 +189,10 @@ function BlockView({ block, values, setValue, disabled, onFire }: { block: Admin
|
|
|
188
189
|
</div>
|
|
189
190
|
);
|
|
190
191
|
case "table":
|
|
191
|
-
|
|
192
|
+
// The only interactive-capable block that used NOT to get the value bag. A row could
|
|
193
|
+
// show that a venue is hidden and could not offer the switch, so a list of 800 rows
|
|
194
|
+
// had to be written as 800 `actions` blocks — a table with the table taken out.
|
|
195
|
+
return <TableBlock block={block} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />;
|
|
192
196
|
case "image":
|
|
193
197
|
return (
|
|
194
198
|
<figure className="m-0">
|
|
@@ -222,7 +226,7 @@ function BlockView({ block, values, setValue, disabled, onFire }: { block: Admin
|
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
|
|
225
|
-
function TableBlock({ block }: { block: Extract<AdminBlock, { type: "table" }
|
|
229
|
+
function TableBlock({ block, values, setValue, disabled, onFire }: { block: Extract<AdminBlock, { type: "table" }>; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
226
230
|
if (block.rows.length === 0) return <p className="text-sm text-fg-subtle">{block.empty ?? "Nothing here."}</p>;
|
|
227
231
|
return (
|
|
228
232
|
// Wide tables scroll INSIDE their own container; the page must not scroll sideways.
|
|
@@ -239,7 +243,9 @@ function TableBlock({ block }: { block: Extract<AdminBlock, { type: "table" }> }
|
|
|
239
243
|
{block.rows.map((row, i) => (
|
|
240
244
|
<tr key={i}>
|
|
241
245
|
{block.columns.map((c) => (
|
|
242
|
-
<td key={c.key} className="border-b border-border px-3 py-2 text-fg">
|
|
246
|
+
<td key={c.key} className="border-b border-border px-3 py-2 text-fg">
|
|
247
|
+
<CellView value={row[c.key]} blockId={block.block_id} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />
|
|
248
|
+
</td>
|
|
243
249
|
))}
|
|
244
250
|
</tr>
|
|
245
251
|
))}
|
|
@@ -249,9 +255,31 @@ function TableBlock({ block }: { block: Extract<AdminBlock, { type: "table" }> }
|
|
|
249
255
|
);
|
|
250
256
|
}
|
|
251
257
|
|
|
252
|
-
|
|
258
|
+
/** One cell: a value to read, or a control to act with.
|
|
259
|
+
*
|
|
260
|
+
* A cell's element is the SAME `ElementView` an `actions` block renders, on the same page
|
|
261
|
+
* value bag and the same `onFire` — a row's button is not a special kind of button, it is a
|
|
262
|
+
* button that happens to sit in a row. What identifies the row is the button's `value`,
|
|
263
|
+
* which is the idiom that already existed ("one `action_id` can serve a row"); an input in a
|
|
264
|
+
* cell has to carry a per-row `action_id` instead, since the bag is keyed by it, and the
|
|
265
|
+
* server refuses a response where two of them collide. */
|
|
266
|
+
function CellView({ value, blockId, values, setValue, disabled, onFire }: { value: AdminCell | undefined; blockId?: string; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
267
|
+
if (isElementCell(value)) return <ElementView el={value} blockId={blockId} compact values={values} setValue={setValue} disabled={disabled} onFire={onFire} />;
|
|
268
|
+
return <>{cell(value)}</>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A cell is a value or an element, told apart by shape — the same discrimination the server
|
|
272
|
+
* enforces on the way out, so nothing else can be an object by the time it gets here. */
|
|
273
|
+
function isElementCell(v: AdminCell | undefined): v is AdminElement {
|
|
274
|
+
return v !== null && typeof v === "object" && (ADMIN_ELEMENT_TYPES as readonly string[]).includes(v.type);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function cell(v: AdminCell | undefined): string {
|
|
253
278
|
if (v === null || v === undefined) return "";
|
|
254
279
|
if (typeof v === "boolean") return v ? "yes" : "no";
|
|
280
|
+
// An object that is not an element cannot reach a browser through a checked response; if
|
|
281
|
+
// one does, it is named rather than stringified into `[object Object]`.
|
|
282
|
+
if (typeof v === "object") return `[unsupported cell: ${String(v.type)}]`;
|
|
255
283
|
return String(v);
|
|
256
284
|
}
|
|
257
285
|
|
|
@@ -260,28 +288,46 @@ function ActionsBlock({ block, values, setValue, disabled, onFire }: { block: Ex
|
|
|
260
288
|
// `onFire` attaches the whole bag, so a filter in this block reaches a button in another.
|
|
261
289
|
return (
|
|
262
290
|
<div className="flex flex-wrap items-end gap-3">
|
|
263
|
-
{block.elements.map((el, i) =>
|
|
264
|
-
el.type === "button" ?
|
|
265
|
-
|
|
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
|
-
)}
|
|
291
|
+
{block.elements.map((el, i) => (
|
|
292
|
+
<ElementView key={el.type === "button" ? `b:${i}` : `i:${el.action_id}`} el={el} blockId={block.block_id} values={values} setValue={setValue} disabled={disabled} onFire={onFire} />
|
|
293
|
+
))}
|
|
281
294
|
</div>
|
|
282
295
|
);
|
|
283
296
|
}
|
|
284
297
|
|
|
298
|
+
/**
|
|
299
|
+
* One element — a button that fires, or an input bound to the page's value bag.
|
|
300
|
+
*
|
|
301
|
+
* Shared by `actions` and by a table cell so that the two cannot drift: a row's control has
|
|
302
|
+
* to reach the handler with exactly what a toolbar control reaches it with, or "the button
|
|
303
|
+
* in the row" becomes a second, weaker kind of button.
|
|
304
|
+
*
|
|
305
|
+
* `compact` is a rendering decision, not vocabulary: a control inside a data row is not a
|
|
306
|
+
* toolbar call-to-action, and 800 pill buttons at CTA size make a table unreadable. The
|
|
307
|
+
* server says what the control IS; the host decides how big it draws.
|
|
308
|
+
*/
|
|
309
|
+
function ElementView({ el, blockId, compact, values, setValue, disabled, onFire }: { el: AdminElement; blockId?: string; compact?: boolean; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
310
|
+
if (el.type === "button") {
|
|
311
|
+
return (
|
|
312
|
+
<Button
|
|
313
|
+
size={compact ? "sm" : undefined}
|
|
314
|
+
variant={el.style === "primary" ? "primary" : el.style === "danger" ? "ghost" : "secondary"}
|
|
315
|
+
className={el.style === "danger" ? "text-danger" : undefined}
|
|
316
|
+
isDisabled={disabled}
|
|
317
|
+
onPress={() => {
|
|
318
|
+
if (el.confirm && !confirm(el.confirm)) return;
|
|
319
|
+
// `value` is what identifies the ROW: one `action_id` serves every row of a table,
|
|
320
|
+
// and the value says which one it was.
|
|
321
|
+
onFire({ type: "block_action", action_id: el.action_id, block_id: blockId, value: el.value ?? null });
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
{el.label}
|
|
325
|
+
</Button>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return <InputView input={el} value={values[el.action_id]} onChange={(v) => setValue(el.action_id, v)} disabled={disabled} />;
|
|
329
|
+
}
|
|
330
|
+
|
|
285
331
|
function FormBlock({ block, values, setValue, disabled, onFire }: { block: Extract<AdminBlock, { type: "form" }>; disabled: boolean; onFire: (f: Fired) => void } & ValueBag) {
|
|
286
332
|
// No local state: the page owns the bag and re-seeds it from every response, so a form
|
|
287
333
|
// cannot keep values the server has since replaced. (The `block_id` key in `BlockList` is
|
|
@@ -313,20 +359,27 @@ function FormBlock({ block, values, setValue, disabled, onFire }: { block: Extra
|
|
|
313
359
|
|
|
314
360
|
const isInput = (el: AdminElement): el is AdminInput => el.type !== "button";
|
|
315
361
|
|
|
316
|
-
/** Every input on a page, in declaration order, including those
|
|
317
|
-
* `accordion`. The page seeds its whole value bag from this on every
|
|
362
|
+
/** Every input on a page, in declaration order, including those in table cells and those
|
|
363
|
+
* nested in `columns` and `accordion`. The page seeds its whole value bag from this on every
|
|
364
|
+
* response — an input the walk misses renders empty however good its `initial_value` was,
|
|
365
|
+
* and then submits that emptiness. */
|
|
318
366
|
function collectInputs(blocks: readonly AdminBlock[], out: AdminInput[] = []): AdminInput[] {
|
|
319
367
|
for (const b of blocks) {
|
|
320
368
|
if (b.type === "form") out.push(...b.fields);
|
|
321
369
|
else if (b.type === "actions") out.push(...b.elements.filter(isInput));
|
|
370
|
+
// Only cells a COLUMN names, because only those are rendered — the same set the server
|
|
371
|
+
// checks for colliding `action_id`s, so the two halves agree on what is on the page.
|
|
372
|
+
else if (b.type === "table") for (const row of b.rows) for (const c of b.columns) { const v = row[c.key]; if (isElementCell(v) && isInput(v)) out.push(v); }
|
|
322
373
|
else if (b.type === "columns") for (const col of b.columns) collectInputs(col, out);
|
|
323
374
|
else if (b.type === "accordion") collectInputs(b.blocks, out);
|
|
324
375
|
}
|
|
325
376
|
return out;
|
|
326
377
|
}
|
|
327
378
|
|
|
328
|
-
/** The page's value bag as a fresh response describes it.
|
|
329
|
-
|
|
379
|
+
/** The page's value bag as a fresh response describes it. Exported for the test that holds
|
|
380
|
+
* the seeding contract: it is a pure function of one response, and a table cell's input is
|
|
381
|
+
* only reachable through it. */
|
|
382
|
+
export function seedValues(blocks: readonly AdminBlock[]): BlockValues {
|
|
330
383
|
return initialValues(collectInputs(blocks));
|
|
331
384
|
}
|
|
332
385
|
|
|
@@ -353,11 +406,31 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
353
406
|
</span>
|
|
354
407
|
) : null;
|
|
355
408
|
|
|
409
|
+
/**
|
|
410
|
+
* The server's verdict on THIS field, under the field it is about.
|
|
411
|
+
*
|
|
412
|
+
* The page-level `toast` was the only failure surface there was, and it is the wrong one
|
|
413
|
+
* for "25:00 is not a time" or "the end is before the start": it names no field, it is
|
|
414
|
+
* gone in three seconds, and it floats at the top of a form whose sixth input is the
|
|
415
|
+
* problem. It cannot be worked around from the page either — `form` renders a flat list of
|
|
416
|
+
* inputs, so a page cannot interleave a `context` block to put the message where it
|
|
417
|
+
* belongs.
|
|
418
|
+
*
|
|
419
|
+
* It is drawn from the response, not from state: the whole page re-renders on every
|
|
420
|
+
* interaction, so the error is exactly as old as the values beside it and there is nothing
|
|
421
|
+
* to invalidate. `role="alert"` because it appears in answer to something the user just
|
|
422
|
+
* did, so a screen reader has to be told without being asked.
|
|
423
|
+
*/
|
|
424
|
+
const error = input.error ? (
|
|
425
|
+
<span role="alert" className="text-caption text-danger">{input.error}</span>
|
|
426
|
+
) : null;
|
|
427
|
+
|
|
356
428
|
if (input.type === "toggle") {
|
|
357
429
|
return (
|
|
358
430
|
<label className="flex items-center gap-2">
|
|
359
|
-
<input type="checkbox" checked={value === true} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
|
|
431
|
+
<input type="checkbox" checked={value === true} disabled={disabled} aria-invalid={input.error ? true : undefined} onChange={(e) => onChange(e.target.checked)} />
|
|
360
432
|
<span className="text-sm text-fg">{input.label ?? input.action_id}</span>
|
|
433
|
+
{error}
|
|
361
434
|
</label>
|
|
362
435
|
);
|
|
363
436
|
}
|
|
@@ -366,7 +439,7 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
366
439
|
<label className="flex min-w-[180px] flex-col gap-1.5">
|
|
367
440
|
{label}
|
|
368
441
|
{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)}>
|
|
442
|
+
<select className={CONTROL} value={typeof value === "string" ? value : ""} disabled={disabled} aria-invalid={input.error ? true : undefined} aria-label={input.label ?? input.action_id} onChange={(e) => onChange(e.target.value)}>
|
|
370
443
|
<option value="">—</option>
|
|
371
444
|
{input.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
372
445
|
</select>
|
|
@@ -378,6 +451,7 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
378
451
|
max={input.max}
|
|
379
452
|
placeholder={input.placeholder}
|
|
380
453
|
disabled={disabled}
|
|
454
|
+
aria-invalid={input.error ? true : undefined}
|
|
381
455
|
aria-label={input.label ?? input.action_id}
|
|
382
456
|
value={typeof value === "number" ? String(value) : ""}
|
|
383
457
|
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
|
|
@@ -387,6 +461,7 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
387
461
|
className={`${CONTROL} h-auto min-h-24 py-2.5`}
|
|
388
462
|
placeholder={input.placeholder}
|
|
389
463
|
disabled={disabled}
|
|
464
|
+
aria-invalid={input.error ? true : undefined}
|
|
390
465
|
aria-label={input.label ?? input.action_id}
|
|
391
466
|
value={typeof value === "string" ? value : ""}
|
|
392
467
|
onChange={(e) => onChange(e.target.value)}
|
|
@@ -400,11 +475,13 @@ function InputView({ input, value, onChange, disabled }: { input: AdminInput; va
|
|
|
400
475
|
autoComplete={input.type === "secret_input" ? "new-password" : undefined}
|
|
401
476
|
placeholder={input.placeholder}
|
|
402
477
|
disabled={disabled}
|
|
478
|
+
aria-invalid={input.error ? true : undefined}
|
|
403
479
|
aria-label={input.label ?? input.action_id}
|
|
404
480
|
value={typeof value === "string" ? value : ""}
|
|
405
481
|
onChange={(e) => onChange(e.target.value)}
|
|
406
482
|
/>
|
|
407
483
|
)}
|
|
484
|
+
{error}
|
|
408
485
|
</label>
|
|
409
486
|
);
|
|
410
487
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Where you are, for the app bar.
|
|
2
|
+
//
|
|
3
|
+
// The trail has two parts and they come from different places, which is the whole design
|
|
4
|
+
// here. The SECTION is derivable — the layout already knows which nav entry is lit, and that
|
|
5
|
+
// entry carries both a label and the route back to its list. The DETAIL is not: `/pages/:id`
|
|
6
|
+
// carries an opaque id, and the page's title only exists inside the editor that fetched it.
|
|
7
|
+
// So the section is computed in the layout and the detail is PUBLISHED by whatever is on
|
|
8
|
+
// screen, through the hook below.
|
|
9
|
+
//
|
|
10
|
+
// It matters most exactly where the screen header is absent. A list screen already says
|
|
11
|
+
// "Media" in 56px immediately under the bar, so the crumb there is a mild echo; a page editor
|
|
12
|
+
// has no header at all — three columns of panels and a "← all pages" button — and the trail is
|
|
13
|
+
// the only thing naming what is open.
|
|
14
|
+
//
|
|
15
|
+
// A LEAF module: `components.tsx` and `furniture.tsx` both publish into it, and
|
|
16
|
+
// `components.tsx` already imports from `furniture.tsx`, so anything they share has to sit
|
|
17
|
+
// below both.
|
|
18
|
+
|
|
19
|
+
import { createContext, useContext, useEffect, type ReactNode } from "react";
|
|
20
|
+
|
|
21
|
+
/** Only the SETTER travels down. The value lives in the layout, which is also what renders
|
|
22
|
+
* the bar — handing the value back down as well would be a second copy of state that exists
|
|
23
|
+
* to be read by the component that owns it. */
|
|
24
|
+
const PublishCrumb = createContext<(label: string | null) => void>(() => {});
|
|
25
|
+
|
|
26
|
+
export function BreadcrumbProvider({ publish, children }: { publish: (label: string | null) => void; children: ReactNode }) {
|
|
27
|
+
return <PublishCrumb.Provider value={publish}>{children}</PublishCrumb.Provider>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Publish the trailing crumb for the screen that is mounted.
|
|
32
|
+
*
|
|
33
|
+
* Pass `undefined` while the record is still loading and the bar simply shows the section —
|
|
34
|
+
* better than a crumb that says "Loading…" and then changes, which draws the eye to the one
|
|
35
|
+
* place on screen that should be still.
|
|
36
|
+
*
|
|
37
|
+
* The cleanup is not optional: without it a crumb outlives its screen, so leaving a page
|
|
38
|
+
* editor for a list would leave the page's title sitting in the bar next to a different
|
|
39
|
+
* section. Effect-based rather than render-time, because publishing during render is a state
|
|
40
|
+
* update in another component's render pass.
|
|
41
|
+
*/
|
|
42
|
+
export function useCrumb(label: string | null | undefined): void {
|
|
43
|
+
const publish = useContext(PublishCrumb);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
publish(label ?? null);
|
|
46
|
+
return () => publish(null);
|
|
47
|
+
}, [label, publish]);
|
|
48
|
+
}
|
package/src/chrome.ts
CHANGED
|
@@ -14,3 +14,30 @@ export const WRAP = "mx-auto max-w-[1200px] px-7 pb-8 pt-2";
|
|
|
14
14
|
|
|
15
15
|
/** One row in a list — a card-surfaced strip with the standard inset. */
|
|
16
16
|
export const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
|
|
17
|
+
|
|
18
|
+
// --- the app bar, and what has to clear it -----------------------------------------------
|
|
19
|
+
//
|
|
20
|
+
// Two classes for ONE number. The app bar (the rail toggle + the account menu) is sticky at
|
|
21
|
+
// the top of the content column, and the screen header is sticky BELOW it — so the header's
|
|
22
|
+
// offset has to be the bar's height, exactly. They live here, together, because the two are
|
|
23
|
+
// rendered by different modules (`routes/_layout.tsx` and `page-header.tsx`) and a magic `44`
|
|
24
|
+
// written out in each is a one-pixel gap or overlap waiting for the next edit. Tailwind needs
|
|
25
|
+
// literal class names, so this is a pair of strings rather than a length.
|
|
26
|
+
|
|
27
|
+
/** The app bar's height. */
|
|
28
|
+
export const APP_BAR_H = "h-11";
|
|
29
|
+
|
|
30
|
+
/** …and the sticky offset anything pinned beneath it must use. Same 44px. */
|
|
31
|
+
export const BELOW_APP_BAR = "top-11";
|
|
32
|
+
|
|
33
|
+
// --- the page editor's own toolbar ---------------------------------------------------------
|
|
34
|
+
//
|
|
35
|
+
// Same "two classes for one number" rule as the app bar above, one level deeper. The editor's
|
|
36
|
+
// toolbar is sticky BELOW the app bar, and the inspector column is sticky below THAT — three
|
|
37
|
+
// elements, two of which need to know the height of what is above them.
|
|
38
|
+
|
|
39
|
+
/** The page editor's toolbar height. */
|
|
40
|
+
export const PAGE_TOOLBAR_H = "h-14";
|
|
41
|
+
|
|
42
|
+
/** …and the offset for anything pinned beneath it: the app bar (44px) plus the toolbar (56). */
|
|
43
|
+
export const BELOW_PAGE_TOOLBAR = "top-[6.25rem]";
|