@pramen/cms-editor 0.0.60 → 0.0.63
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 +18 -3
- package/dist/editor.css +1 -1
- package/dist/editor.js +155 -155
- package/package.json +3 -2
- package/src/api.ts +23 -6
- package/src/app-context.tsx +4 -0
- package/src/app.css +48 -0
- 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/nav.ts +149 -15
- package/src/page-header.tsx +128 -0
- package/src/preview.ts +65 -0
- package/src/routes/_layout.tsx +511 -93
- package/src/routes/page.tsx +4 -0
- package/src/types.ts +62 -0
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
|
+
}
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Where a minted preview token is redeemed.
|
|
2
|
+
//
|
|
3
|
+
// A LEAF module, like `chrome.ts`: the page editor and the collection editor both mint
|
|
4
|
+
// links, and the rule for turning a mint into an href has to be one rule.
|
|
5
|
+
//
|
|
6
|
+
// The CMS is headless. `signPagePreview` returns a token plus a relative url that the CMS
|
|
7
|
+
// Worker redeems itself — and that endpoint answers with JSON, because the server has the
|
|
8
|
+
// draft and no idea what it should look like. That is the right default for a machine and
|
|
9
|
+
// the wrong one for the person preview exists for: a stakeholder without an account, sent a
|
|
10
|
+
// link, who opens a wall of braces.
|
|
11
|
+
//
|
|
12
|
+
// So a host may declare where ITS OWN site renders a preview. Same seam as `menuHref` and
|
|
13
|
+
// the sitemap's `pageUrl` — the CMS cannot know how a deployment routes, so the deployment
|
|
14
|
+
// says. Unset, nothing changes: the link still points at the backend.
|
|
15
|
+
|
|
16
|
+
/** What the host may set under `window.PRAMEN_CMS_EDITOR.previewUrl`. */
|
|
17
|
+
interface PreviewHost {
|
|
18
|
+
PRAMEN_CMS_EDITOR?: { previewUrl?: unknown };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The site's own preview route, if the shell declared a usable one.
|
|
22
|
+
*
|
|
23
|
+
* Anything that is not a non-empty string is ignored rather than coerced — a `previewUrl`
|
|
24
|
+
* of `true` or `0` would otherwise build a link to `"true?token=…"`, which fails as a
|
|
25
|
+
* broken page rather than as a configuration error anyone can see. */
|
|
26
|
+
export function sitePreviewUrl(host: PreviewHost = globalThis as PreviewHost): string | undefined {
|
|
27
|
+
const raw = host.PRAMEN_CMS_EDITOR?.previewUrl;
|
|
28
|
+
if (typeof raw !== "string") return undefined;
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
return trimmed === "" ? undefined : trimmed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Build the href for a minted PAGE preview. ALWAYS ABSOLUTE.
|
|
34
|
+
*
|
|
35
|
+
* `resolve` is the fallback: the backend-relative url the mint returned, made absolute
|
|
36
|
+
* against the CMS origin — exactly what this did before a site route could be declared.
|
|
37
|
+
*
|
|
38
|
+
* Absolute because the result is not only navigated to, it is COPIED and shown: half the
|
|
39
|
+
* reason the button exists is to send the link to someone who has no account. `previewUrl` is
|
|
40
|
+
* normally written as a path (`"/preview"`), so the configured — recommended — path was the
|
|
41
|
+
* one that produced `/preview?token=…` in the clipboard: dead the moment it is pasted into
|
|
42
|
+
* Slack. The new tab hid it, because a blank window opened by this document inherits its base
|
|
43
|
+
* URL and resolves the relative href perfectly well.
|
|
44
|
+
*
|
|
45
|
+
* `origin` is passed rather than read from `location` so this stays a pure function that can
|
|
46
|
+
* be tested; an already-absolute `siteUrl` is left alone by `new URL`. */
|
|
47
|
+
export function pagePreviewHref(
|
|
48
|
+
minted: { url: string; token: string },
|
|
49
|
+
opts: { siteUrl?: string; origin: string; resolve: (path: string) => string },
|
|
50
|
+
): string {
|
|
51
|
+
const site = opts.siteUrl;
|
|
52
|
+
if (!site) return opts.resolve(minted.url);
|
|
53
|
+
// The declared route may already carry query params (a locale, a layout switch), so the
|
|
54
|
+
// separator is decided rather than assumed — `?` twice is a link that silently drops the
|
|
55
|
+
// token into a parameter name.
|
|
56
|
+
const sep = site.includes("?") ? "&" : "?";
|
|
57
|
+
const href = `${site}${sep}token=${encodeURIComponent(minted.token)}`;
|
|
58
|
+
try {
|
|
59
|
+
return new URL(href, opts.origin).href;
|
|
60
|
+
} catch {
|
|
61
|
+
// An unparseable origin (or a `previewUrl` that is not a URL at all) must not throw in
|
|
62
|
+
// the middle of minting — the relative href is what this returned before and still opens.
|
|
63
|
+
return href;
|
|
64
|
+
}
|
|
65
|
+
}
|