@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
package/src/nav.ts
CHANGED
|
@@ -31,6 +31,41 @@ export interface ExtraNavLink {
|
|
|
31
31
|
order?: number;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** The glyphs the sidebar can draw, by NAME.
|
|
35
|
+
*
|
|
36
|
+
* A name, not a component: this module is a pure function of the session's facts and is
|
|
37
|
+
* tested without a DOM, so it must not import JSX. `NAV_GLYPHS` in `icons.tsx` maps each
|
|
38
|
+
* name to the drawing, and because this is a closed union a glyph added here without a
|
|
39
|
+
* drawing is a compile error rather than a blank square in the nav. */
|
|
40
|
+
export type NavGlyph =
|
|
41
|
+
| "pages"
|
|
42
|
+
| "collection"
|
|
43
|
+
| "media"
|
|
44
|
+
| "menus"
|
|
45
|
+
| "taxonomies"
|
|
46
|
+
| "widgets"
|
|
47
|
+
| "redirects"
|
|
48
|
+
| "app"
|
|
49
|
+
| "types"
|
|
50
|
+
| "users"
|
|
51
|
+
| "settings"
|
|
52
|
+
| "link";
|
|
53
|
+
|
|
54
|
+
/** What sits in an entry's icon slot.
|
|
55
|
+
*
|
|
56
|
+
* Two cases because a collection and a Block Kit page may declare their OWN icon
|
|
57
|
+
* server-side, and that is an arbitrary string (an emoji, in practice). It used to be
|
|
58
|
+
* prepended to the label — which read as part of the words, wrapped with them, and could
|
|
59
|
+
* not be aligned with anything. In a sidebar the icon is a column, so a declared emoji goes
|
|
60
|
+
* in that column and the built-in glyph is the fallback. */
|
|
61
|
+
export type NavIcon = { kind: "glyph"; name: NavGlyph } | { kind: "emoji"; char: string };
|
|
62
|
+
|
|
63
|
+
/** A server-declared icon if there is one, else the section's own glyph. */
|
|
64
|
+
function iconFor(declared: string | undefined, fallback: NavGlyph): NavIcon {
|
|
65
|
+
const char = declared?.trim();
|
|
66
|
+
return char ? { kind: "emoji", char } : { kind: "glyph", name: fallback };
|
|
67
|
+
}
|
|
68
|
+
|
|
34
69
|
/** One entry in the primary nav.
|
|
35
70
|
*
|
|
36
71
|
* `kind` is what the layout switches on to render it; everything else here is the data it
|
|
@@ -38,8 +73,8 @@ export interface ExtraNavLink {
|
|
|
38
73
|
* buzola page, and the layout is where navigation (and the unsaved-changes guard it runs
|
|
39
74
|
* through) belongs. */
|
|
40
75
|
export type NavEntry =
|
|
41
|
-
| { kind: "route"; key: string; order: number; label: string; page: NavPage; params?: Record<string, string> }
|
|
42
|
-
| { kind: "link"; key: string; order: number; link: ExtraNavLink };
|
|
76
|
+
| { kind: "route"; key: string; order: number; icon: NavIcon; label: string; page: NavPage; params?: Record<string, string> }
|
|
77
|
+
| { kind: "link"; key: string; order: number; icon: NavIcon; link: ExtraNavLink };
|
|
43
78
|
|
|
44
79
|
/** What the nav is built from. All of it is already in the app context; passing it in keeps
|
|
45
80
|
* this a pure function of the session's facts. */
|
|
@@ -76,10 +111,10 @@ export function buildNav(input: NavInput): NavEntry[] {
|
|
|
76
111
|
// slug. The label is the type's own `name`, so a host that wants a plural tab writes
|
|
77
112
|
// one.
|
|
78
113
|
for (const t of contentTypes ?? []) {
|
|
79
|
-
entries.push({ kind: "route", key: `type:${t.slug}`, order: NAV_ORDER.pages, label: t.name, page: "type", params: { slug: t.slug } });
|
|
114
|
+
entries.push({ kind: "route", key: `type:${t.slug}`, order: NAV_ORDER.pages, icon: { kind: "glyph", name: "pages" }, label: t.name, page: "type", params: { slug: t.slug } });
|
|
80
115
|
}
|
|
81
116
|
} else {
|
|
82
|
-
entries.push({ kind: "route", key: "pages", order: NAV_ORDER.pages, label: "Pages", page: "home" });
|
|
117
|
+
entries.push({ kind: "route", key: "pages", order: NAV_ORDER.pages, icon: { kind: "glyph", name: "pages" }, label: "Pages", page: "home" });
|
|
83
118
|
}
|
|
84
119
|
}
|
|
85
120
|
|
|
@@ -88,22 +123,23 @@ export function buildNav(input: NavInput): NavEntry[] {
|
|
|
88
123
|
kind: "route",
|
|
89
124
|
key: `col:${c.slug}`,
|
|
90
125
|
order: c.navOrder ?? NAV_ORDER.collections,
|
|
91
|
-
|
|
126
|
+
icon: iconFor(c.icon, "collection"),
|
|
127
|
+
label: c.pluralLabel,
|
|
92
128
|
page: "collection",
|
|
93
129
|
params: { slug: c.slug },
|
|
94
130
|
});
|
|
95
131
|
}
|
|
96
132
|
|
|
97
|
-
entries.push({ kind: "route", key: "media", order: NAV_ORDER.media, label: "Media", page: "media" });
|
|
133
|
+
entries.push({ kind: "route", key: "media", order: NAV_ORDER.media, icon: { kind: "glyph", name: "media" }, label: "Media", page: "media" });
|
|
98
134
|
|
|
99
135
|
if (cms.siteFurniture) {
|
|
100
|
-
entries.push({ kind: "route", key: "menus", order: NAV_ORDER.menus, label: "Menus", page: "menus" });
|
|
136
|
+
entries.push({ kind: "route", key: "menus", order: NAV_ORDER.menus, icon: { kind: "glyph", name: "menus" }, label: "Menus", page: "menus" });
|
|
101
137
|
// Taxonomies classify PAGES — `cms_page_terms` links a term to a page and to nothing
|
|
102
138
|
// else — so a collections-only deployment has nothing to classify and the section would
|
|
103
139
|
// be a vocabulary editor with no subject.
|
|
104
|
-
if (!hidePages) entries.push({ kind: "route", key: "taxonomies", order: NAV_ORDER.taxonomies, label: "Taxonomies", page: "taxonomies" });
|
|
105
|
-
entries.push({ kind: "route", key: "widgets", order: NAV_ORDER.widgets, label: "Widgets", page: "widgets" });
|
|
106
|
-
entries.push({ kind: "route", key: "redirects", order: NAV_ORDER.redirects, label: "Redirects", page: "redirects" });
|
|
140
|
+
if (!hidePages) entries.push({ kind: "route", key: "taxonomies", order: NAV_ORDER.taxonomies, icon: { kind: "glyph", name: "taxonomies" }, label: "Taxonomies", page: "taxonomies" });
|
|
141
|
+
entries.push({ kind: "route", key: "widgets", order: NAV_ORDER.widgets, icon: { kind: "glyph", name: "widgets" }, label: "Widgets", page: "widgets" });
|
|
142
|
+
entries.push({ kind: "route", key: "redirects", order: NAV_ORDER.redirects, icon: { kind: "glyph", name: "redirects" }, label: "Redirects", page: "redirects" });
|
|
107
143
|
}
|
|
108
144
|
|
|
109
145
|
// A project's own screens, INSIDE the chrome and at a position they choose. This is the
|
|
@@ -111,24 +147,122 @@ export function buildNav(input: NavInput): NavEntry[] {
|
|
|
111
147
|
// the odd 10% of a client site was a separate deployment that looked nothing like the
|
|
112
148
|
// admin it hung off.
|
|
113
149
|
for (const p of adminPages) {
|
|
114
|
-
entries.push({ kind: "route", key: `app:${p.slug}`, order: p.navOrder ?? NAV_ORDER.adminPages,
|
|
150
|
+
entries.push({ kind: "route", key: `app:${p.slug}`, order: p.navOrder ?? NAV_ORDER.adminPages, icon: iconFor(p.icon, "app"), label: p.label, page: "admin-page", params: { slug: p.slug } });
|
|
115
151
|
}
|
|
116
152
|
|
|
117
153
|
// Authoring the SCHEMA, not content — so it is gated on `canEdit` (every handler behind
|
|
118
154
|
// it is editor-only) and hidden where there is no block/page builder to define types for.
|
|
119
155
|
if (!hidePages && cms.canEdit) {
|
|
120
|
-
entries.push({ kind: "route", key: "types", order: NAV_ORDER.types, label: "Types", page: "schema" });
|
|
156
|
+
entries.push({ kind: "route", key: "types", order: NAV_ORDER.types, icon: { kind: "glyph", name: "types" }, label: "Types", page: "schema" });
|
|
121
157
|
}
|
|
122
158
|
|
|
123
|
-
if (isAdmin) entries.push({ kind: "route", key: "users", order: NAV_ORDER.users, label: "Users", page: "users" });
|
|
124
|
-
entries.push({ kind: "route", key: "settings", order: NAV_ORDER.settings, label: "Settings", page: "settings" });
|
|
159
|
+
if (isAdmin) entries.push({ kind: "route", key: "users", order: NAV_ORDER.users, icon: { kind: "glyph", name: "users" }, label: "Users", page: "users" });
|
|
160
|
+
entries.push({ kind: "route", key: "settings", order: NAV_ORDER.settings, icon: { kind: "glyph", name: "settings" }, label: "Settings", page: "settings" });
|
|
125
161
|
|
|
126
162
|
for (const link of extraNav) {
|
|
127
163
|
// Keyed on href AND label: two entries may legitimately point at the same href and
|
|
128
164
|
// differ only in label or target, and keyed on href alone React reconciles them
|
|
129
165
|
// together — the rendered label can end up on the other one's anchor.
|
|
130
|
-
entries.push({ kind: "link", key: `extra:${link.href}|${link.label}`, order: link.order ?? NAV_ORDER.extra, link });
|
|
166
|
+
entries.push({ kind: "link", key: `extra:${link.href}|${link.label}`, order: link.order ?? NAV_ORDER.extra, icon: { kind: "glyph", name: "link" }, link });
|
|
131
167
|
}
|
|
132
168
|
|
|
133
169
|
return entries.sort((a, b) => a.order - b.order);
|
|
134
170
|
}
|
|
171
|
+
|
|
172
|
+
// --- sections ---------------------------------------------------------------------------
|
|
173
|
+
//
|
|
174
|
+
// Twelve items in one flat list is the thing that stopped being legible — it overran the
|
|
175
|
+
// topbar, and a sidebar alone only turns a crowded row into a long column. So the nav is
|
|
176
|
+
// GROUPED. Grouping rather than hiding: everything a session may reach stays one click away
|
|
177
|
+
// (a submenu costs a click and hides the thing being looked for), and the headings answer
|
|
178
|
+
// "where would I look for this" instead of making the reader scan twelve equal rows.
|
|
179
|
+
//
|
|
180
|
+
// A section is a BAND OF `order`, not a hand-written list of keys. That keeps the one
|
|
181
|
+
// contract this module has — position is a number a host sets (`navOrder` on a collection
|
|
182
|
+
// or an admin page, `order` on an `extraNav` link) — the single thing that decides where an
|
|
183
|
+
// entry appears. A host placing a section at 250 lands in Content, at 450 in Site, exactly
|
|
184
|
+
// as the number reads. Enumerating keys instead would have made a host-placed entry land
|
|
185
|
+
// visually inside a group it was not a member of.
|
|
186
|
+
|
|
187
|
+
/** A group of nav entries, in order. */
|
|
188
|
+
export type NavSectionId = "content" | "site" | "apps" | "system";
|
|
189
|
+
|
|
190
|
+
export interface NavSection {
|
|
191
|
+
id: NavSectionId;
|
|
192
|
+
/** The heading. Rendered only when there is more than one section — see `navSections`. */
|
|
193
|
+
label: string;
|
|
194
|
+
entries: NavEntry[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The bands, in order. `upTo` is EXCLUSIVE, and each is expressed against `NAV_ORDER`
|
|
198
|
+
* rather than a literal so the two cannot drift: a built-in moving to a new position moves
|
|
199
|
+
* with its band. */
|
|
200
|
+
const BANDS: readonly { id: NavSectionId; label: string; upTo: number }[] = [
|
|
201
|
+
// Pages / content types, collections, media — the things an editor came here to write.
|
|
202
|
+
{ id: "content", label: "Content", upTo: NAV_ORDER.menus },
|
|
203
|
+
// Menus, taxonomies, widget areas, redirects — site-level furniture, not page content.
|
|
204
|
+
{ id: "site", label: "Site", upTo: NAV_ORDER.adminPages },
|
|
205
|
+
// A project's own Block Kit screens. Their own band rather than a tail of "System",
|
|
206
|
+
// because they are the project's, and the whole point of `adminPage()` is that they are
|
|
207
|
+
// not administration of the CMS.
|
|
208
|
+
{ id: "apps", label: "Apps", upTo: NAV_ORDER.types },
|
|
209
|
+
// Types, users, settings, and host links to companion tools.
|
|
210
|
+
{ id: "system", label: "System", upTo: Number.POSITIVE_INFINITY },
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
/** Every section id, in rail order. Derived from `BANDS` rather than written out again, so
|
|
214
|
+
* a new group cannot exist in the layout's eyes but not in the reader's stored folds (or the
|
|
215
|
+
* reverse). It is what makes a persisted fold PARSEABLE: a value read back out of
|
|
216
|
+
* localStorage is checked against this, so a stale id from an older version — or anything a
|
|
217
|
+
* hand-edit put there — is dropped rather than carried into state as "some string". */
|
|
218
|
+
export const NAV_SECTION_IDS: readonly NavSectionId[] = BANDS.map((b) => b.id);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Group the entries `buildNav` returned, dropping empty sections.
|
|
222
|
+
*
|
|
223
|
+
* Takes the already-ordered list rather than re-deriving it: the order IS the grouping, so
|
|
224
|
+
* a second traversal that could disagree with the first would be a bug waiting to happen.
|
|
225
|
+
* Entries stay in the order they came in, which is what carries the sub-orderings that have
|
|
226
|
+
* no numeric expression (content-type tabs among themselves, collections sharing a
|
|
227
|
+
* `navOrder`).
|
|
228
|
+
*/
|
|
229
|
+
export function navSections(entries: NavEntry[]): NavSection[] {
|
|
230
|
+
const sections: NavSection[] = BANDS.map((b) => ({ id: b.id, label: b.label, entries: [] }));
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
// The last band is unbounded, so `find` always hits; the `??` is for the type checker.
|
|
233
|
+
const i = BANDS.findIndex((b) => entry.order < b.upTo);
|
|
234
|
+
(sections[i === -1 ? sections.length - 1 : i] as NavSection).entries.push(entry);
|
|
235
|
+
}
|
|
236
|
+
return sections.filter((s) => s.entries.length > 0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Should the sections be LABELLED?
|
|
241
|
+
*
|
|
242
|
+
* A single heading over the whole nav names nothing — it is a caption on a list with no
|
|
243
|
+
* sibling to distinguish it from — and a collections-only deployment (`hidePages`, no site
|
|
244
|
+
* furniture) genuinely is one group. So the headings appear once there are at least two,
|
|
245
|
+
* and the small deployment keeps a plain list.
|
|
246
|
+
*/
|
|
247
|
+
export function navSectionsAreLabelled(sections: NavSection[]): boolean {
|
|
248
|
+
return sections.length > 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Is the rail ACTUALLY narrowed?
|
|
253
|
+
*
|
|
254
|
+
* A named function for `choice && wide`, because conflating those two is what broke it. The
|
|
255
|
+
* choice is per-browser and persisted; narrowing is expressed entirely in `md:`-scoped
|
|
256
|
+
* classes, so it only exists at desktop widths. A rail narrowed on a laptop therefore came
|
|
257
|
+
* back "narrowed" on a phone, where every one of those classes is inert — the rows kept their
|
|
258
|
+
* labels and their full width, while the JS gated on the stored choice removed all four group
|
|
259
|
+
* headings, and the hairline that stands in for a heading at 56px is `md:`-only too. One
|
|
260
|
+
* undifferentiated column of a dozen rows, and the toggle that would undo it is
|
|
261
|
+
* `hidden md:inline-flex`: no way back from that viewport.
|
|
262
|
+
*
|
|
263
|
+
* So JS has to agree with the breakpoint rather than ignore it, and everything conditional —
|
|
264
|
+
* headings, folding, the hairline — reads this instead of the stored value.
|
|
265
|
+
*/
|
|
266
|
+
export function railIsNarrow(choice: boolean, wideViewport: boolean): boolean {
|
|
267
|
+
return choice && wideViewport;
|
|
268
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The screen header: a cover panel carrying the title and the primary action, which STAYS as
|
|
2
|
+
// the page scrolls.
|
|
3
|
+
//
|
|
4
|
+
// A LEAF module, and it has to be one. `components.tsx` already imports from `furniture.tsx`
|
|
5
|
+
// (for `flattenTerms`), so the two near-identical headers that used to live one in each — a
|
|
6
|
+
// 56px `Hero` and a 40px `Head` — could not be deduplicated by either importing the other
|
|
7
|
+
// without a cycle. Below both, they are one component.
|
|
8
|
+
//
|
|
9
|
+
// ONE type scale, and no `size` prop. The 40px variant was inherited from `Head`: the
|
|
10
|
+
// site-furniture screens (Menus, Taxonomies, Widgets, Redirects) had always been set quieter
|
|
11
|
+
// than the libraries, back when a header was type on the page and the difference read as
|
|
12
|
+
// hierarchy. It does not survive the cover: every screen now gets the same panel with the same
|
|
13
|
+
// generated artwork, so a smaller title on four of them reads as an oversight rather than a
|
|
14
|
+
// rank, and "Navigation" at 40px beside "Media" at 56px is a question a reader has to stop and
|
|
15
|
+
// ask.
|
|
16
|
+
//
|
|
17
|
+
// STICKY, and condensing. The header is the only thing on screen that says which of a dozen
|
|
18
|
+
// interchangeable list screens you are on, and scrolling a media library past the first row
|
|
19
|
+
// used to take it away — leaving a wall of thumbnails with no title and no primary action.
|
|
20
|
+
// Sticky alone would be worse than that: a 190px banner pinned to the top eats a third of the
|
|
21
|
+
// viewport on every scroll. So it condenses — same panel, same artwork, same button, a third
|
|
22
|
+
// of the height — which is the only version of "keep it" that a long list can afford.
|
|
23
|
+
|
|
24
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
25
|
+
import { BELOW_APP_BAR } from "./chrome";
|
|
26
|
+
import { CoverArt } from "./cover";
|
|
27
|
+
|
|
28
|
+
/** TWO thresholds, not one — this is hysteresis, and without it the header flickers.
|
|
29
|
+
*
|
|
30
|
+
* Condensing REMOVES about 90px of header, which shortens the document by the same amount. If
|
|
31
|
+
* the reader is anywhere the browser then has to clamp the scroll offset, `scrollY` moves on
|
|
32
|
+
* its own — back under a single threshold, which expands the header, which lengthens the
|
|
33
|
+
* document, which moves `scrollY` again. The state is an input to its own condition, so one
|
|
34
|
+
* threshold is a feedback loop by construction and no amount of debouncing fixes it: the
|
|
35
|
+
* oscillation is in the layout, not in the event rate.
|
|
36
|
+
*
|
|
37
|
+
* The gap has to exceed the height the switch removes, or the loop simply reappears further
|
|
38
|
+
* down. `EXPAND_BELOW` is deliberately small so that returning to the top always restores the
|
|
39
|
+
* full header.
|
|
40
|
+
*/
|
|
41
|
+
const CONDENSE_ABOVE_PX = 120;
|
|
42
|
+
const EXPAND_BELOW_PX = 16;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Has the page scrolled far enough to condense the header?
|
|
46
|
+
*
|
|
47
|
+
* On `window`, because the editor's shell deliberately keeps the DOCUMENT as its scroller (the
|
|
48
|
+
* rail is a sticky grid column, not an inner scroll pane) — so this is the one scroll position
|
|
49
|
+
* there is. `passive` since nothing here can cancel the scroll, and React bails out of a
|
|
50
|
+
* re-render when the value is unchanged, so a scroll inside the dead band costs a comparison.
|
|
51
|
+
*/
|
|
52
|
+
function useCondensed(): boolean {
|
|
53
|
+
const [condensed, setCondensed] = useState(false);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
// Reads the CURRENT value rather than closing over it, so the listener is registered once
|
|
56
|
+
// and the dead band is evaluated against what is actually on screen.
|
|
57
|
+
const onScroll = (): void =>
|
|
58
|
+
setCondensed((was) => (was ? window.scrollY > EXPAND_BELOW_PX : window.scrollY > CONDENSE_ABOVE_PX));
|
|
59
|
+
onScroll(); // a deep link can land already scrolled (browser scroll restoration)
|
|
60
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
61
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
62
|
+
}, []);
|
|
63
|
+
return condensed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The header.
|
|
68
|
+
*
|
|
69
|
+
* Seeded on `lead`, NOT on `em`. `lead` is the screen's name ("Media", a collection's plural
|
|
70
|
+
* label) and `em` is its state ("None yet", "3 files") — seeding on what is displayed would
|
|
71
|
+
* redraw the artwork every time someone uploaded a file, which is the one thing a picture
|
|
72
|
+
* meant to make a screen RECOGNISABLE must never do.
|
|
73
|
+
*
|
|
74
|
+
* The mask over the art is a hard requirement, not polish: the title is large and set left, and
|
|
75
|
+
* `from-surface-card` fading to transparent is what guarantees it never lands on pattern
|
|
76
|
+
* whatever the seed produced. The `via` stop is the whole tuning knob — at `/85` the middle of
|
|
77
|
+
* the panel is still 85% panel colour and the artwork only ever appears in the last third,
|
|
78
|
+
* which is a cover you cannot see; `/70` clears the type (which ends around 26% of the width)
|
|
79
|
+
* and lets the field read across the rest.
|
|
80
|
+
*/
|
|
81
|
+
export function PageHeader({ lead, em, children }: { lead: string; em: string; children?: ReactNode }) {
|
|
82
|
+
const condensed = useCondensed();
|
|
83
|
+
return (
|
|
84
|
+
// The GUTTER is what sticks, not the panel: pinning the panel alone would let rows scroll
|
|
85
|
+
// through the 28px of page margin either side of it and out the rounded corners. `bg-surface`
|
|
86
|
+
// on this wrapper is therefore load-bearing, not decoration.
|
|
87
|
+
//
|
|
88
|
+
// NO padding above: the panel meets the app bar directly, so the two read as one block of
|
|
89
|
+
// chrome rather than a bar with a panel parked under it. That also leaves nothing on this
|
|
90
|
+
// wrapper that changes between the two states, which is why the collapse transition lives
|
|
91
|
+
// only on the panel below — a transition on an element whose padding is constant is dead
|
|
92
|
+
// code that looks load-bearing.
|
|
93
|
+
//
|
|
94
|
+
// `z-20` sits above the list and below the rail's mobile disclosure (which is in flow above
|
|
95
|
+
// it) and every modal overlay (z-50).
|
|
96
|
+
<div className={`sticky ${BELOW_APP_BAR} z-20 mx-auto max-w-[1200px] bg-surface px-7 pb-4`}>
|
|
97
|
+
<div className="relative isolate overflow-hidden rounded-panel border border-border bg-surface-card">
|
|
98
|
+
<CoverArt seed={lead} />
|
|
99
|
+
<div className="absolute inset-0 bg-gradient-to-r from-surface-card via-surface-card/70 to-transparent" />
|
|
100
|
+
<div
|
|
101
|
+
className={`relative grid grid-cols-[1fr_auto] items-center gap-6 px-8 transition-[padding] duration-150 ease-out max-[820px]:grid-cols-1 ${
|
|
102
|
+
condensed ? "py-3.5" : "py-9"
|
|
103
|
+
}`}
|
|
104
|
+
>
|
|
105
|
+
{/* Condensed, the two halves run on ONE line. Stacked they would keep the full
|
|
106
|
+
header's height and defeat the point; and the title has to stay a single `<h1>`
|
|
107
|
+
across the switch, or a screen reader is handed a new heading every time the
|
|
108
|
+
reader scrolls. */}
|
|
109
|
+
<h1
|
|
110
|
+
// NO transition on the type. `transition-all` here animated `font-size` 56px -> 22px,
|
|
111
|
+
// which relayouts the panel on every frame of the tween — on a STICKY element whose
|
|
112
|
+
// height feeds back into the scroll position, which is the other half of the flicker.
|
|
113
|
+
// And `display` (two stacked lines -> one baseline row) cannot be tweened at all, so
|
|
114
|
+
// the line count snapped mid-animation regardless. The type switches at once; the
|
|
115
|
+
// padding below carries the motion.
|
|
116
|
+
className={`m-0 font-normal tracking-[-0.01em] ${
|
|
117
|
+
condensed ? "flex items-baseline gap-2 text-[22px] leading-tight" : "text-[56px] leading-[1.05] max-[820px]:text-[40px]"
|
|
118
|
+
}`}
|
|
119
|
+
>
|
|
120
|
+
<span className={condensed ? "text-fg-subtle" : "block text-fg-subtle"}>{lead}</span>
|
|
121
|
+
<span className={condensed ? "text-fg" : "block text-fg"}>{em}</span>
|
|
122
|
+
</h1>
|
|
123
|
+
{children}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The blast radius of a panel.
|
|
2
|
+
//
|
|
3
|
+
// A panel is a project's own component rendering inside THIS app's React tree, which is the
|
|
4
|
+
// whole point of it — and the cost of that is that a throw in its render is a throw in ours.
|
|
5
|
+
// React's answer to an uncaught render error is to unmount the entire root, so without a
|
|
6
|
+
// boundary one bad panel does not break a screen, it blanks the admin: no sidebar, no way to
|
|
7
|
+
// navigate off the route that is failing, and a reload lands straight back on it because the
|
|
8
|
+
// URL is a real route.
|
|
9
|
+
//
|
|
10
|
+
// So the panel route wraps it. The chrome survives, the reader is told which panel failed and
|
|
11
|
+
// with what, and every other section stays one click away.
|
|
12
|
+
//
|
|
13
|
+
// A CLASS, because `componentDidCatch`/`getDerivedStateFromError` have no hook equivalent —
|
|
14
|
+
// there is still no way to catch a render error from a function component. Its own module so
|
|
15
|
+
// the decision is testable without dragging in the router and the design system.
|
|
16
|
+
|
|
17
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Named in the message — a deployment with three panels needs to know which one. */
|
|
21
|
+
slug: string;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface State {
|
|
26
|
+
/** The failure's message, or `null` while the panel is fine. */
|
|
27
|
+
failure: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a caught error reads as. A named function because it is the one piece of judgement
|
|
31
|
+
* here: an error with no message (a thrown string, a thrown object) must still produce
|
|
32
|
+
* something a reader can act on, and `String(undefined)` is not it. */
|
|
33
|
+
export function panelFailureMessage(error: unknown): string {
|
|
34
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
35
|
+
return message.trim() === "" ? "threw a value with no message" : message;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PanelBoundary extends Component<Props, State> {
|
|
39
|
+
override state: State = { failure: null };
|
|
40
|
+
|
|
41
|
+
static getDerivedStateFromError(error: unknown): State {
|
|
42
|
+
return { failure: panelFailureMessage(error) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
46
|
+
// The rendered message names the panel and the error; the console gets the component
|
|
47
|
+
// stack, which is the half a developer needs and a reader cannot use.
|
|
48
|
+
console.error(`pramen/cms-editor: panel '${this.props.slug}' failed to render`, error, info.componentStack);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override componentDidUpdate(prev: Props): void {
|
|
52
|
+
// Navigating to a different panel must clear the failure — the route keys on the slug so
|
|
53
|
+
// this rarely fires, but a boundary that latched would turn one panel's bug into every
|
|
54
|
+
// panel's bug for the rest of the session.
|
|
55
|
+
if (prev.slug !== this.props.slug && this.state.failure !== null) this.setState({ failure: null });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override render(): ReactNode {
|
|
59
|
+
if (this.state.failure === null) return this.props.children;
|
|
60
|
+
return (
|
|
61
|
+
<div role="alert" className="rounded-panel border border-border bg-surface-card px-6 py-5 text-sm text-fg-muted">
|
|
62
|
+
<p className="mb-1 text-fg">The <strong>{this.props.slug}</strong> panel failed to render.</p>
|
|
63
|
+
<p className="font-mono text-caption">{this.state.failure}</p>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The three shim modules a PANEL bundle's bare imports resolve to.
|
|
2
|
+
//
|
|
3
|
+
// A panel is a project's own React screen, built as a separate bundle and rendered inside
|
|
4
|
+
// this editor's tree. It cannot contain React: two copies in one page share no hook
|
|
5
|
+
// dispatcher, so the panel's first `useState` throws "invalid hook call". It must instead
|
|
6
|
+
// link against the React the editor already loaded — which is published on a global by
|
|
7
|
+
// `panel-runtime.ts`, and reached from a panel's ordinary `import { useState } from "react"`
|
|
8
|
+
// through an import map in the shell (see `PramenAdmin.astro`) pointing at these files.
|
|
9
|
+
//
|
|
10
|
+
// GENERATED, not hand-written, and that is the whole reason this module exists rather than
|
|
11
|
+
// three checked-in files. A shim has to re-export every name STATICALLY — ESM named exports
|
|
12
|
+
// cannot be computed — so by hand the list would be a copy of React's export table that
|
|
13
|
+
// nobody would ever revisit. A name missing from it is not a build error anywhere: it is a
|
|
14
|
+
// browser link error ("does not provide an export named 'useDeferredValue'") in someone
|
|
15
|
+
// else's panel, months later, on the one deployment that used that hook. So the list is read
|
|
16
|
+
// off the very modules the editor bundles, at the moment it bundles them, and cannot drift
|
|
17
|
+
// from them by construction.
|
|
18
|
+
//
|
|
19
|
+
// Source-side rather than inside `scripts/build.ts` so the generator is typechecked and can
|
|
20
|
+
// be exercised by tests — `test/cms-editor-panel-globals.test.ts` builds a panel against the
|
|
21
|
+
// generated text and renders it, which is the only way to prove the mechanism end to end
|
|
22
|
+
// without a browser.
|
|
23
|
+
|
|
24
|
+
/** A module namespace as this generator must treat one: an opaque bag whose NAMES are the
|
|
25
|
+
* entire subject. There is no schema to decode it against — the point is that the list comes
|
|
26
|
+
* from React rather than from anything written here — so the contract is deliberately just
|
|
27
|
+
* "an object with a possible CJS-interop `default`". */
|
|
28
|
+
export interface ModuleNamespace {
|
|
29
|
+
readonly default?: ModuleNamespace;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One shim: which bare specifier it stands in for, which key on the published runtime it
|
|
33
|
+
* reads, and what it is written to. */
|
|
34
|
+
export interface PanelGlobalShim {
|
|
35
|
+
/** Filename under `dist/`, and the package export a shell imports with `?url`. */
|
|
36
|
+
readonly file: string;
|
|
37
|
+
/** The bare specifier an import map points at this file. */
|
|
38
|
+
readonly specifier: string;
|
|
39
|
+
/** The property of `PRAMEN_CMS_EDITOR_RUNTIME` holding the namespace. */
|
|
40
|
+
readonly runtimeKey: "react" | "reactDom" | "jsxRuntime" | "jsxDevRuntime";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The whole set. Four, and meant to stay four — see the surface note in `panel-runtime.ts`
|
|
44
|
+
* for why each is here and what is deliberately not. */
|
|
45
|
+
export const PANEL_GLOBAL_SHIMS: readonly PanelGlobalShim[] = [
|
|
46
|
+
{ file: "panel-react.js", specifier: "react", runtimeKey: "react" },
|
|
47
|
+
{ file: "panel-react-dom.js", specifier: "react-dom", runtimeKey: "reactDom" },
|
|
48
|
+
{ file: "panel-jsx-runtime.js", specifier: "react/jsx-runtime", runtimeKey: "jsxRuntime" },
|
|
49
|
+
// The transform a panel built UNMINIFIED emits instead. Not an optional nicety: without
|
|
50
|
+
// it that specifier resolves from the consumer's own node_modules and a second React ends
|
|
51
|
+
// up in the bundle, silently, on exactly the build a developer iterates on.
|
|
52
|
+
{ file: "panel-jsx-dev-runtime.js", specifier: "react/jsx-dev-runtime", runtimeKey: "jsxDevRuntime" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The names a shim may re-export, read off a module namespace: real identifiers only.
|
|
57
|
+
*
|
|
58
|
+
* The namespace is unwrapped through `default` first, because React and its JSX runtimes are
|
|
59
|
+
* CJS: a bundler's interop puts the real `module.exports` on `default` and mirrors the named
|
|
60
|
+
* exports beside it. Enumerating the unwrapped object means the list is the one `require`
|
|
61
|
+
* would have produced, rather than one that depends on which interop the editor happened to
|
|
62
|
+
* be built with — and it is the same unwrap the generated shim performs at runtime, so the
|
|
63
|
+
* names written out are exactly the names that will be there to destructure.
|
|
64
|
+
*
|
|
65
|
+
* `default` is excluded because it is a keyword (it gets its own `export default` line), and
|
|
66
|
+
* `__`-prefixed keys because React's namespaces carry interop bookkeeping and internals
|
|
67
|
+
* (`__esModule`, `__CLIENT_INTERNALS_…`) that are nobody's API — re-exporting them would put
|
|
68
|
+
* this package's name on a promise React itself does not make.
|
|
69
|
+
*
|
|
70
|
+
* Sorted, so a rebuild against the same React produces a byte-identical file and a diff of
|
|
71
|
+
* `dist/` says something.
|
|
72
|
+
*/
|
|
73
|
+
export function exportableNames(ns: ModuleNamespace): string[] {
|
|
74
|
+
const real = ns.default ?? ns;
|
|
75
|
+
return Object.keys(real)
|
|
76
|
+
.filter((k) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) && k !== "default" && !k.startsWith("__"))
|
|
77
|
+
.sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The shim's source.
|
|
82
|
+
*
|
|
83
|
+
* The `?.` and the throw are not defensive noise. This is the first module a panel bundle
|
|
84
|
+
* imports, so it is the one place where "the editor did not publish its runtime" — a panel
|
|
85
|
+
* loaded outside the admin, or a shell that emitted the import map but not the editor — can
|
|
86
|
+
* be reported as itself instead of as `Cannot read properties of undefined (reading
|
|
87
|
+
* 'react')` from somewhere inside a stranger's bundle.
|
|
88
|
+
*
|
|
89
|
+
* `ns.default ?? ns` is the same CJS-interop unwrap `exportableNames` performs, and it has to
|
|
90
|
+
* be: the names written into this file were read off the unwrapped object, so the object
|
|
91
|
+
* destructured here must be that object and not the namespace around it.
|
|
92
|
+
*/
|
|
93
|
+
export function panelShimSource(shim: PanelGlobalShim, names: readonly string[], runtimeGlobal: string): string {
|
|
94
|
+
return `// GENERATED by @pramen/cms-editor's build — do not edit.
|
|
95
|
+
// Resolves the bare specifier "${shim.specifier}" for a panel bundle, against the React the
|
|
96
|
+
// editor already loaded. Wired up by the shell's import map; see docs/cms.md.
|
|
97
|
+
const ns = globalThis.${runtimeGlobal}?.${shim.runtimeKey};
|
|
98
|
+
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.");
|
|
99
|
+
const m = ns.default ?? ns;
|
|
100
|
+
export default m;
|
|
101
|
+
export const { ${names.join(", ")} } = m;
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// What the editor bundle publishes on `globalThis` for panel bundles to build against.
|
|
2
|
+
//
|
|
3
|
+
// A panel is a separate bundle that renders into THIS bundle's React tree. Two copies of
|
|
4
|
+
// React in one page share no hook dispatcher, so a second copy does not degrade — the first
|
|
5
|
+
// `useState` in a panel throws "invalid hook call" and the screen is a blank error. The
|
|
6
|
+
// panel bundle therefore cannot contain React; it must import the one already here.
|
|
7
|
+
//
|
|
8
|
+
// So the editor publishes its React (plus react-dom and both JSX runtimes) on a global, and
|
|
9
|
+
// the shell's import map points those bare specifiers at four tiny shim modules that read it
|
|
10
|
+
// back out — see `panel-globals.ts`, which GENERATES the shims from the very namespaces
|
|
11
|
+
// published here, so the names they re-export cannot drift from the React actually loaded.
|
|
12
|
+
//
|
|
13
|
+
// The consequence, and the whole point: a panel is written as ordinary React, with ordinary
|
|
14
|
+
// `import { useState } from "react"`, built with react/react-dom marked external. Nothing
|
|
15
|
+
// about the source says it is a panel except the one `registerPanel` call. That is what
|
|
16
|
+
// makes an existing standalone screen portable rather than rewritable.
|
|
17
|
+
//
|
|
18
|
+
// THE SURFACE IS FOUR NAMESPACES AND ONE FUNCTION, and it is meant to stay that size —
|
|
19
|
+
// every name here is a thing this package can never move again:
|
|
20
|
+
//
|
|
21
|
+
// - `react` — the shared copy. Non-negotiable; it is the reason this exists.
|
|
22
|
+
// - `reactDom` — shared for the same reason, one level down. A panel that bundled its own
|
|
23
|
+
// react-dom would be a SECOND RECONCILER driving one React, which is worse than a second
|
|
24
|
+
// React because it can appear to work. It is also where `createPortal` lives, and a
|
|
25
|
+
// dialog is the first thing a panel needs that Block Kit could not express.
|
|
26
|
+
// - `jsxRuntime` / `jsxDevRuntime` — the automatic JSX transform emits imports from
|
|
27
|
+
// `react/jsx-runtime`, or from `react/jsx-dev-runtime` when the panel is built
|
|
28
|
+
// unminified. A panel bundle does not choose this, its compiler does — and the dev half
|
|
29
|
+
// is the one that MUST be here: left out, a development build resolves that specifier
|
|
30
|
+
// from the consumer's own node_modules and quietly bundles a second React, which is the
|
|
31
|
+
// exact failure this whole mechanism exists to prevent, arriving on the one build where
|
|
32
|
+
// nobody is looking for it.
|
|
33
|
+
// - `registerPanel` — the registration itself, and the gate: it refuses a bundle whose
|
|
34
|
+
// stated contract is not the one this editor implements, naming the slug and the fix.
|
|
35
|
+
// See `PANEL_RUNTIME_CONTRACT` in `panels.ts` for what bumps that number, and the note
|
|
36
|
+
// below for why the number itself is not published here.
|
|
37
|
+
//
|
|
38
|
+
// Not published, and each for a reason: the `Api` class (a panel gets the narrow `PanelApi`
|
|
39
|
+
// through props — see `panels.ts`), the router (a panel owns its own screen, not the
|
|
40
|
+
// editor's routing table), the podoba component library (it is a dependency a panel can
|
|
41
|
+
// install itself, and freezing OUR version of it as a global API is a promise this package
|
|
42
|
+
// should not make), and the app context (it carries the CMS's own state, none of which is a
|
|
43
|
+
// project screen's business).
|
|
44
|
+
|
|
45
|
+
import * as react from "react";
|
|
46
|
+
import * as reactDom from "react-dom";
|
|
47
|
+
import * as jsxRuntime from "react/jsx-runtime";
|
|
48
|
+
import * as jsxDevRuntime from "react/jsx-dev-runtime";
|
|
49
|
+
import { registerPanel, type PanelRegistration } from "./panels";
|
|
50
|
+
|
|
51
|
+
/** Where the runtime is published. Namespaced away from `PRAMEN_CMS_EDITOR`, which is the
|
|
52
|
+
* SHELL's config: that object is written by the server and read by the editor, this one is
|
|
53
|
+
* written by the editor and read by panel bundles, and putting them together would invite a
|
|
54
|
+
* shell to think it may set part of this. */
|
|
55
|
+
export const PANEL_RUNTIME_GLOBAL = "PRAMEN_CMS_EDITOR_RUNTIME";
|
|
56
|
+
|
|
57
|
+
// THE CONTRACT NUMBER IS NOT ON THIS OBJECT, and that omission is the point of the check
|
|
58
|
+
// rather than a gap in it. A panel must state the contract it was BUILT against; publishing
|
|
59
|
+
// ours would put the answer key beside the question, since
|
|
60
|
+
// `PRAMEN_CMS_EDITOR_RUNTIME.contract` is shorter to write than the literal and would satisfy
|
|
61
|
+
// every editor forever — the check would then be this editor comparing its number to its
|
|
62
|
+
// number. The number a panel states is `PANEL_RUNTIME_CONTRACT` in `panels.ts`, taken from
|
|
63
|
+
// the docs or from a refusal message, which prints both sides. It was published here once,
|
|
64
|
+
// read by nothing, and that is exactly what a version guarantee looks like when it is only a
|
|
65
|
+
// field.
|
|
66
|
+
|
|
67
|
+
export interface PanelRuntime {
|
|
68
|
+
readonly react: typeof react;
|
|
69
|
+
readonly reactDom: typeof reactDom;
|
|
70
|
+
readonly jsxRuntime: typeof jsxRuntime;
|
|
71
|
+
readonly jsxDevRuntime: typeof jsxDevRuntime;
|
|
72
|
+
registerPanel(def: PanelRegistration): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The host object a panel runtime is published on. */
|
|
76
|
+
export interface PanelRuntimeHost {
|
|
77
|
+
[PANEL_RUNTIME_GLOBAL]?: PanelRuntime;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function panelRuntime(): PanelRuntime {
|
|
81
|
+
return {
|
|
82
|
+
react,
|
|
83
|
+
reactDom,
|
|
84
|
+
jsxRuntime,
|
|
85
|
+
jsxDevRuntime,
|
|
86
|
+
// Wrapped rather than passed by reference so the published function is this module's,
|
|
87
|
+
// not the registry's — the registry keeps a second parameter (its warning sink) that is
|
|
88
|
+
// a test seam and must not become part of the surface a panel bundle can reach.
|
|
89
|
+
registerPanel: (def) => registerPanel(def),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Publish the runtime. Called at the very top of `main.tsx`, before anything is imported
|
|
95
|
+
* dynamically and before the router mounts, so that by the time a panel bundle's first
|
|
96
|
+
* `import "react"` is evaluated the global is already there.
|
|
97
|
+
*
|
|
98
|
+
* Publishing is unconditional — it does not wait to see whether any panel is configured.
|
|
99
|
+
* The object is four references; making it conditional would mean a deployment that adds a
|
|
100
|
+
* panel later has a second thing to switch on, and a debugging session that starts with
|
|
101
|
+
* "is the runtime there?" would have two answers.
|
|
102
|
+
*/
|
|
103
|
+
export function publishPanelRuntime(host: PanelRuntimeHost): void {
|
|
104
|
+
host[PANEL_RUNTIME_GLOBAL] = panelRuntime();
|
|
105
|
+
}
|