@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/routes/_layout.tsx
CHANGED
|
@@ -1,17 +1,115 @@
|
|
|
1
|
-
// Root layout: the persistent chrome (
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Root layout: the persistent chrome (sidebar nav + global error banner) wrapped around
|
|
2
|
+
// every route via <Outlet />. The active section is derived from the current path, so a deep
|
|
3
|
+
// link or refresh lands with the right item lit.
|
|
4
|
+
//
|
|
5
|
+
// The nav is a SIDEBAR, not the topbar row it used to be. The podoba/Graphic Standard topbar
|
|
6
|
+
// is a brand-left / few-tabs-right bar, and this admin outgrew it: Pages, one tab per
|
|
7
|
+
// content type, N collections, Media, Menus, Taxonomies, Widgets, Redirects, Apps, Types,
|
|
8
|
+
// Users, Settings and any host links is a dozen-plus destinations, which on a 1280px screen
|
|
9
|
+
// left the tabs a dense unlabelled ribbon and below that a horizontal scroller — a nav whose
|
|
10
|
+
// items you have to scroll to discover. A column has room for all of them AT ONCE, plus an
|
|
11
|
+
// icon per row and headings that group them (see `navSections`), which is the same
|
|
12
|
+
// information in a shape that can be scanned rather than read.
|
|
13
|
+
//
|
|
14
|
+
// It is shaped like NOTION's rail, which is the shape this kind of nav has converged on and
|
|
15
|
+
// the one an editor already knows how to read:
|
|
16
|
+
//
|
|
17
|
+
// - a GROUND of its own — the rail is `surface-card`, the content `surface` — so the two
|
|
18
|
+
// regions separate without a rule between them. In DARK the two tokens are the same
|
|
19
|
+
// colour by design (podoba flattens the card onto the surface), so there the hairline
|
|
20
|
+
// does the separating; that is why the border is unconditional and the tone is not.
|
|
21
|
+
// - DENSE rows: 13px label, 15px icon, ~26px tall. The rail is a list to scan, not a strip
|
|
22
|
+
// of buttons to aim at, and the podoba pill (`rounded-full`, `py-2`) is the latter.
|
|
23
|
+
// - COLLAPSIBLE groups. This is the "hide some of it in a submenu" answer, without the
|
|
24
|
+
// click a submenu costs on the way IN: everything is one click away by default, and a
|
|
25
|
+
// deployment that never touches Site or System folds them away for good (persisted).
|
|
26
|
+
//
|
|
27
|
+
// Everything else stays podoba: rows are still `Button variant="ghost"`, the type is
|
|
28
|
+
// `text-compact`, and every colour is a token (`surface-card` / `surface-muted` / `fg-muted`
|
|
29
|
+
// / `border`). What changed is the chrome's LAYOUT and DENSITY, not the design system.
|
|
4
30
|
|
|
5
31
|
import { Outlet, useNavigate, useRoute, useRouter } from "@buzola/router";
|
|
6
|
-
import { Button, Card,
|
|
7
|
-
import { useEffect, useState } from "react";
|
|
8
|
-
import { useApp } from "../app-context";
|
|
32
|
+
import { Avatar, Button, Card, Text, UserMenu, UserMenuItem } from "@podoba/react";
|
|
33
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
34
|
+
import { useApp, type Me } from "../app-context";
|
|
35
|
+
import { BreadcrumbProvider } from "../breadcrumb";
|
|
9
36
|
import { BRAND } from "../brand";
|
|
37
|
+
import { GroupFoldedIcon as CrumbSeparatorIcon } from "../icons";
|
|
10
38
|
import { pagesHidden, splitsByType } from "../components";
|
|
39
|
+
import { APP_BAR_H } from "../chrome";
|
|
40
|
+
import { DarkThemeIcon, GroupFoldedIcon, GroupOpenIcon, LightThemeIcon, MenuToggleIcon, NAV_GLYPHS, RailToggleIcon, SettingsIcon, SignOutIcon } from "../icons";
|
|
11
41
|
import { opensInSameTab } from "../mount";
|
|
12
|
-
import { buildNav, type ExtraNavLink } from "../nav";
|
|
42
|
+
import { buildNav, NAV_SECTION_IDS, navSections, navSectionsAreLabelled, railIsNarrow, type ExtraNavLink, type NavIcon, type NavSectionId } from "../nav";
|
|
13
43
|
|
|
14
44
|
const THEME_KEY = "pramen.cms.theme";
|
|
45
|
+
/** Which nav groups this browser has folded away. Per-browser, like the theme — it is a
|
|
46
|
+
* reading preference, not deployment configuration, and nothing server-side should carry it. */
|
|
47
|
+
const COLLAPSED_KEY = "pramen.cms.nav.collapsed";
|
|
48
|
+
/** …and whether the whole rail is down to icons. Same reasoning, separate key: folding a
|
|
49
|
+
* group and narrowing the rail are different decisions and a reader makes them separately. */
|
|
50
|
+
const RAIL_KEY = "pramen.cms.nav.rail";
|
|
51
|
+
|
|
52
|
+
/** Tailwind's `md`. The rail's narrow state is expressed entirely in `md:`-scoped classes, so
|
|
53
|
+
* JS has to agree with the same number or the two describe different rails. */
|
|
54
|
+
const RAIL_BREAKPOINT = "(min-width: 48rem)";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a media query matches, kept in sync as the viewport changes.
|
|
58
|
+
*
|
|
59
|
+
* SSR-safe and paranoid about the API: `matchMedia` is absent in a non-browser render and has
|
|
60
|
+
* been present-but-partial (no `addEventListener`, only the deprecated `addListener`) in
|
|
61
|
+
* browsers this editor still meets. Defaults to TRUE, which is the desktop reading — the same
|
|
62
|
+
* direction the layout already fails in, and the one where every control is on screen.
|
|
63
|
+
*/
|
|
64
|
+
function useMediaQuery(query: string): boolean {
|
|
65
|
+
const [matches, setMatches] = useState(() => {
|
|
66
|
+
try {
|
|
67
|
+
return globalThis.matchMedia?.(query).matches ?? true;
|
|
68
|
+
} catch {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const mql = globalThis.matchMedia?.(query);
|
|
74
|
+
if (!mql) return;
|
|
75
|
+
const onChange = () => setMatches(mql.matches);
|
|
76
|
+
onChange();
|
|
77
|
+
mql.addEventListener?.("change", onChange);
|
|
78
|
+
return () => mql.removeEventListener?.("change", onChange);
|
|
79
|
+
}, [query]);
|
|
80
|
+
return matches;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether this browser last left the rail narrowed.
|
|
84
|
+
*
|
|
85
|
+
* `try`/`catch` and no `typeof` guard, matching `readCollapsed` below: referencing an absent
|
|
86
|
+
* `localStorage` throws a ReferenceError the catch already handles, and the catch additionally
|
|
87
|
+
* covers the case a `typeof` check cannot — a browser that HAS the object and throws on access
|
|
88
|
+
* (a private window with site data blocked). One shape for both readers. */
|
|
89
|
+
function readRail(): boolean {
|
|
90
|
+
try {
|
|
91
|
+
return localStorage.getItem(RAIL_KEY) === "narrow";
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The folded groups, tolerating every shape localStorage can be in (absent, another
|
|
98
|
+
* version's value, hand-edited, a private window that throws on read). A bad value costs an
|
|
99
|
+
* expanded rail, which is the state the reader can always fix.
|
|
100
|
+
*
|
|
101
|
+
* The read is a PARSE against the closed set of section ids, not a cast: it walks
|
|
102
|
+
* `NAV_SECTION_IDS` and keeps the ones the stored array mentions, so the result is section
|
|
103
|
+
* ids by construction and a stale or invented id simply is not in it. */
|
|
104
|
+
function readCollapsed(): NavSectionId[] {
|
|
105
|
+
try {
|
|
106
|
+
const raw = localStorage.getItem(COLLAPSED_KEY);
|
|
107
|
+
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
|
108
|
+
return Array.isArray(parsed) ? NAV_SECTION_IDS.filter((id) => parsed.includes(id)) : [];
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
15
113
|
|
|
16
114
|
/**
|
|
17
115
|
* The slug segment under `prefix`, DECODED.
|
|
@@ -19,7 +117,7 @@ const THEME_KEY = "pramen.cms.theme";
|
|
|
19
117
|
* `useRoute().pathname` comes off a `URL`, so it is percent-encoded; the slugs it is compared
|
|
20
118
|
* against are the raw values the server stored. buzola encodes when it builds an href and
|
|
21
119
|
* decodes into `params` when it matches, so routing is unaffected — only this comparison was,
|
|
22
|
-
* and a content type called `články` navigated correctly to a
|
|
120
|
+
* and a content type called `články` navigated correctly to a nav with nothing lit.
|
|
23
121
|
*
|
|
24
122
|
* A malformed sequence (`%zz`) throws in `decodeURIComponent`; that cannot match any slug
|
|
25
123
|
* either way, so it degrades to no highlight rather than tearing down the chrome.
|
|
@@ -36,14 +134,14 @@ export function segmentAt(pathname: string, prefix: string): string | undefined
|
|
|
36
134
|
}
|
|
37
135
|
|
|
38
136
|
export default function RootLayout() {
|
|
39
|
-
const { isAdmin, collections, adminPages, contentTypes, cms, error, reconfigure, confirmNavigation } = useApp();
|
|
137
|
+
const { isAdmin, me, collections, adminPages, contentTypes, cms, error, reconfigure, confirmNavigation } = useApp();
|
|
40
138
|
const navigate = useNavigate();
|
|
41
139
|
const { pathname } = useRoute();
|
|
42
140
|
|
|
43
141
|
// Every chrome action here is a way OUT of the current screen, so it runs through that
|
|
44
142
|
// screen's unsaved-changes guard first (the page editor registers one; with no guard
|
|
45
143
|
// registered this is a pass-through). In-app navigation fires no `beforeunload`, so
|
|
46
|
-
// without this the
|
|
144
|
+
// without this the chrome silently discards unsaved edits. An `extraNav` link that opens a
|
|
47
145
|
// NEW tab leaves this document alone and needs no guard; one honoured as `_self` is a real
|
|
48
146
|
// cross-document navigation, so it takes the guard too (below). `beforeunload` is not a
|
|
49
147
|
// fallback for it — only `PageEditor` registers one, so a dirty CollectionEditor form would
|
|
@@ -57,6 +155,52 @@ export default function RootLayout() {
|
|
|
57
155
|
localStorage.setItem(THEME_KEY, theme);
|
|
58
156
|
}, [theme]);
|
|
59
157
|
|
|
158
|
+
// Below `md` the rail collapses to a disclosure under the brand row. A DISCLOSURE, not an
|
|
159
|
+
// overlay drawer: an overlay owes the reader a focus trap, a restore and an Esc handler,
|
|
160
|
+
// and the pieces to do that properly (react-aria's ModalOverlay) are podoba's dependency,
|
|
161
|
+
// not ours — a hand-rolled one would be a modal that keyboard focus walks straight out of.
|
|
162
|
+
// In the flow it is just a `<nav>` that is there or not.
|
|
163
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
164
|
+
// Navigating is what a nav is for, so arriving somewhere closes it. Keyed on the path
|
|
165
|
+
// rather than on each item's handler so an in-page link, the brand button and the back
|
|
166
|
+
// button all close it too.
|
|
167
|
+
useEffect(() => { setMenuOpen(false); }, [pathname]);
|
|
168
|
+
|
|
169
|
+
// Folded groups, remembered. A deployment where nobody touches Site or System should not
|
|
170
|
+
// have to re-fold them every morning, and the state is small enough to keep as a list of
|
|
171
|
+
// ids — the sections are named, so an id that no longer exists simply matches nothing.
|
|
172
|
+
const [collapsed, setCollapsed] = useState<NavSectionId[]>(() => (typeof localStorage === "undefined" ? [] : readCollapsed()));
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
try {
|
|
175
|
+
localStorage.setItem(COLLAPSED_KEY, JSON.stringify(collapsed));
|
|
176
|
+
} catch {
|
|
177
|
+
// A private window that refuses writes costs the memory of the fold, nothing else.
|
|
178
|
+
}
|
|
179
|
+
}, [collapsed]);
|
|
180
|
+
const toggleSection = (id: NavSectionId) =>
|
|
181
|
+
setCollapsed((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]));
|
|
182
|
+
|
|
183
|
+
// The rail, narrowed to its icon column. Remembered like the folds: someone who works in a
|
|
184
|
+
// three-column page editor wants the width back, and wants it back tomorrow too.
|
|
185
|
+
const [railChoice, setRailChoice] = useState(readRail);
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
try {
|
|
188
|
+
localStorage.setItem(RAIL_KEY, railChoice ? "narrow" : "wide");
|
|
189
|
+
} catch {
|
|
190
|
+
/* a private window that refuses writes costs the memory of the choice, nothing else */
|
|
191
|
+
}
|
|
192
|
+
}, [railChoice]);
|
|
193
|
+
// Below `md` the rail is already a disclosure under the wordmark — a width it does not have
|
|
194
|
+
// there. Narrowing is a desktop affordance, and every class that expresses it is `md:`-scoped.
|
|
195
|
+
//
|
|
196
|
+
// Which is why the STORED CHOICE is not the same thing as being narrowed, and JS has to know
|
|
197
|
+
// the difference. The choice is per-browser and persisted, so a rail narrowed on a laptop
|
|
198
|
+
// came back narrowed on a phone — where the `md:` classes do nothing, so the rows kept their
|
|
199
|
+
// labels, but the JS gate below removed every group heading AND the hairline that stands in
|
|
200
|
+
// for one is `md:`-only too. That left one undifferentiated column of a dozen rows, with the
|
|
201
|
+
// toggle that would undo it `hidden md:inline-flex`: no way back from that viewport.
|
|
202
|
+
const railNarrow = railIsNarrow(railChoice, useMediaQuery(RAIL_BREAKPOINT));
|
|
203
|
+
|
|
60
204
|
// The active collection slug, if we're under /collections/:slug(/...).
|
|
61
205
|
const collectionSlug = segmentAt(pathname, "/collections/");
|
|
62
206
|
// …and the active content type, under /types/:slug.
|
|
@@ -65,8 +209,8 @@ export default function RootLayout() {
|
|
|
65
209
|
const appSlug = segmentAt(pathname, "/apps/");
|
|
66
210
|
|
|
67
211
|
// "Pages" stays lit while editing a page (/pages/:id) too — but only on a deployment that
|
|
68
|
-
// still HAS a pooled Pages
|
|
69
|
-
// carries a page id and nothing else, so which type's
|
|
212
|
+
// still HAS a pooled Pages entry. Split by type, the page editor lights nothing: the route
|
|
213
|
+
// carries a page id and nothing else, so which type's entry to light isn't knowable here
|
|
70
214
|
// without fetching the page the editor is already fetching.
|
|
71
215
|
const active = collectionSlug ? `col:${collectionSlug}`
|
|
72
216
|
: typeSlug ? `type:${typeSlug}`
|
|
@@ -74,7 +218,7 @@ export default function RootLayout() {
|
|
|
74
218
|
: pathname.startsWith("/pages") || pathname === "/" ? "pages"
|
|
75
219
|
: pathname.startsWith("/media") ? "media"
|
|
76
220
|
// `/schema` rather than `/types`, because `/types/:slug` is already one content type's
|
|
77
|
-
// PAGE LIST — a different thing entirely, and the
|
|
221
|
+
// PAGE LIST — a different thing entirely, and the entry keyed `type:<slug>` above.
|
|
78
222
|
: pathname.startsWith("/schema") ? "types"
|
|
79
223
|
: pathname.startsWith("/menus") ? "menus"
|
|
80
224
|
: pathname.startsWith("/taxonomies") ? "taxonomies"
|
|
@@ -84,14 +228,6 @@ export default function RootLayout() {
|
|
|
84
228
|
: pathname.startsWith("/settings") ? "settings"
|
|
85
229
|
: "";
|
|
86
230
|
|
|
87
|
-
// `aria-current` alongside the class: split by type the nav is N mutually-exclusive tabs
|
|
88
|
-
// whose only "you are here" cue is a background tint, which is invisible to a screen reader
|
|
89
|
-
// and marginal for anyone who cannot see the tint.
|
|
90
|
-
const tabProps = (key: string) => ({
|
|
91
|
-
className: active === key ? "bg-surface-muted text-fg" : "text-fg-muted",
|
|
92
|
-
...(active === key ? { "aria-current": "page" as const } : {}),
|
|
93
|
-
});
|
|
94
|
-
|
|
95
231
|
// Host-configured links to companion tools (e.g. a curation page), from /config.js.
|
|
96
232
|
const extraNav: ExtraNavLink[] = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.extraNav ?? [] : [];
|
|
97
233
|
// Collections-only deployments hide the block/page builder entirely.
|
|
@@ -99,101 +235,381 @@ export default function RootLayout() {
|
|
|
99
235
|
// Same rule as the landing redirect and the page editor's back target — see `splitsByType`.
|
|
100
236
|
const splitByType = splitsByType(contentTypes, cms, hidePages);
|
|
101
237
|
const nav = buildNav({ collections, adminPages, contentTypes, cms, hidePages, splitByType, isAdmin, extraNav });
|
|
238
|
+
const sections = navSections(nav);
|
|
239
|
+
const labelled = navSectionsAreLabelled(sections);
|
|
240
|
+
// The section half of the breadcrumb, and the way back to its list. Taken from the entry
|
|
241
|
+
// that is LIT rather than re-derived from the path: the nav already answered "where am I",
|
|
242
|
+
// and a second answer computed differently is a second answer that can disagree.
|
|
243
|
+
const activeEntry = nav.find((e) => e.key === active);
|
|
244
|
+
const sectionCrumb = activeEntry?.kind === "route" ? activeEntry : undefined;
|
|
245
|
+
// …and the detail half, published by whatever screen is mounted (see `breadcrumb.tsx`).
|
|
246
|
+
const [crumb, setCrumb] = useState<string | null>(null);
|
|
102
247
|
|
|
103
|
-
// See the extraNav comment
|
|
104
|
-
// depend on; what this supplies is the URL the BROWSER will resolve a
|
|
105
|
-
// against — the current document, not the origin. Empty when there is no
|
|
106
|
-
// makes every href unparseable and so degrades to the safe new-tab default.
|
|
248
|
+
// See the extraNav comment in `NavLink`. The rules live in `mount.ts` beside the
|
|
249
|
+
// containment they depend on; what this supplies is the URL the BROWSER will resolve a
|
|
250
|
+
// relative href against — the current document, not the origin. Empty when there is no
|
|
251
|
+
// `window`, which makes every href unparseable and so degrades to the safe new-tab default.
|
|
107
252
|
const basePath = useRouter().basePath;
|
|
108
253
|
const documentUrl = typeof window !== "undefined" ? window.location.href : "";
|
|
109
254
|
|
|
110
255
|
return (
|
|
111
|
-
// Page-level surface so the whole viewport (not just the
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
size="sm"
|
|
142
|
-
{...tabProps(entry.key)}
|
|
143
|
-
onPress={guarded(() => navigate(entry.page as never, entry.params ? ({ params: entry.params } as never) : undefined as never))}
|
|
256
|
+
// Page-level surface so the whole viewport (not just the rail + cards) flips under
|
|
257
|
+
// `[data-theme="dark"]` — otherwise the body stays white in dark mode. The rail is a grid
|
|
258
|
+
// COLUMN rather than a fixed overlay, so the content column needs no compensating offset
|
|
259
|
+
// and the document keeps its own scroll (which is what the page editor's full-height
|
|
260
|
+
// grid and every in-page anchor already assume).
|
|
261
|
+
<div className={`min-h-screen bg-surface text-fg md:grid ${railNarrow ? "md:grid-cols-[3.5rem_1fr]" : "md:grid-cols-[15rem_1fr]"}`}>
|
|
262
|
+
{/* The rail's own ground. `border-r` is unconditional and the tone is not, because in
|
|
263
|
+
DARK podoba maps `surface-card` onto `surface` — the hairline is the only separation
|
|
264
|
+
left there, and a border that appeared only in one theme would be a rule with no
|
|
265
|
+
reason a reader could see. */}
|
|
266
|
+
<aside
|
|
267
|
+
aria-label="Sidebar"
|
|
268
|
+
className="border-b border-border bg-surface-card md:sticky md:top-0 md:h-screen md:self-start md:overflow-y-auto md:border-b-0 md:border-r"
|
|
269
|
+
>
|
|
270
|
+
<div className="flex min-h-full flex-col">
|
|
271
|
+
<div className={`flex h-12 shrink-0 items-center gap-1 ${railNarrow ? "md:justify-center md:px-0" : ""} px-2`}>
|
|
272
|
+
{/* The workspace row: the wordmark is the way back to the top of the admin, as it
|
|
273
|
+
is on every other site — a `button` (not an `<a>`) so the SPA router handles
|
|
274
|
+
it — and it reads as a row of the rail rather than a masthead above one. */}
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={guarded(() => navigate("home"))}
|
|
278
|
+
aria-label={`${BRAND.spoken} — home`}
|
|
279
|
+
className={`flex min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors hover:bg-surface-muted ${railNarrow ? "md:flex-none md:px-1.5" : "flex-1 px-2"}`}
|
|
280
|
+
>
|
|
281
|
+
{/* The mark. One letter of the deployment's own name, which is the only glyph
|
|
282
|
+
available for a wordmark a host supplies at runtime. */}
|
|
283
|
+
<span
|
|
284
|
+
aria-hidden="true"
|
|
285
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] bg-fg text-[11px] font-bold leading-none text-fg-inverted"
|
|
144
286
|
>
|
|
145
|
-
{
|
|
146
|
-
</
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
287
|
+
{BRAND.name.slice(0, 1).toUpperCase()}
|
|
288
|
+
</span>
|
|
289
|
+
{/* The wordmark itself is what a 56px rail cannot hold; the mark stays, because a
|
|
290
|
+
narrowed rail still has to say whose admin this is. */}
|
|
291
|
+
<span className={`min-w-0 truncate text-compact font-medium leading-4 text-fg ${railNarrow ? "md:hidden" : ""}`}>{BRAND.name}</span>
|
|
292
|
+
{BRAND.suffix ? <span className={`shrink-0 text-caption text-fg-subtle ${railNarrow ? "md:hidden" : ""}`}>{BRAND.suffix}</span> : null}
|
|
293
|
+
</button>
|
|
294
|
+
<Button
|
|
295
|
+
variant="ghost"
|
|
296
|
+
size="sm"
|
|
297
|
+
aria-label={menuOpen ? "Hide navigation" : "Show navigation"}
|
|
298
|
+
aria-expanded={menuOpen}
|
|
299
|
+
aria-controls="pramen-cms-nav"
|
|
300
|
+
className="shrink-0 rounded-md px-2 py-2 text-fg-muted md:hidden"
|
|
301
|
+
onPress={() => setMenuOpen((open) => !open)}
|
|
302
|
+
>
|
|
303
|
+
<MenuToggleIcon className="h-4 w-4" />
|
|
304
|
+
</Button>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<div id="pramen-cms-nav" className={`${menuOpen ? "flex" : "hidden"} flex-1 flex-col md:flex`}>
|
|
308
|
+
<nav aria-label="Primary" className={`flex flex-col pb-2 ${railNarrow ? "md:px-1.5" : ""} px-2`}>
|
|
309
|
+
{sections.map((section) => {
|
|
310
|
+
// Only a LABELLED group can be folded: the header is the control, so without
|
|
311
|
+
// one there would be no way back — and an unlabelled rail is the
|
|
312
|
+
// single-group case, where there is nothing to fold away from anyway.
|
|
313
|
+
// Narrowed, a group cannot be folded: the heading IS the control, and at 56px
|
|
314
|
+
// there is no room for one. The rows show instead — hiding them behind a
|
|
315
|
+
// control that is not on screen would strand a section with no way back.
|
|
316
|
+
const foldable = labelled && !railNarrow;
|
|
317
|
+
const open = !foldable || !collapsed.includes(section.id);
|
|
318
|
+
return (
|
|
319
|
+
// The space between groups lives on the GROUP, not on its heading: a
|
|
320
|
+
// heading is the first child of its own wrapper, so `first:` there matches
|
|
321
|
+
// every one of them and the gap silently never appears.
|
|
322
|
+
<div
|
|
323
|
+
key={section.id}
|
|
324
|
+
className={`flex flex-col gap-px pt-4 first:pt-1 ${
|
|
325
|
+
// A hairline stands in for the heading at 56px: the grouping is still
|
|
326
|
+
// information, and dropping it would leave one undifferentiated column
|
|
327
|
+
// of a dozen icons.
|
|
328
|
+
railNarrow ? "md:mt-2 md:border-t md:border-border md:pt-2 md:first:mt-0 md:first:border-t-0 md:first:pt-1" : ""
|
|
329
|
+
}`}
|
|
330
|
+
>
|
|
331
|
+
{foldable ? (
|
|
332
|
+
<SectionHeader
|
|
333
|
+
label={section.label}
|
|
334
|
+
open={open}
|
|
335
|
+
controls={`nav-group-${section.id}`}
|
|
336
|
+
onPress={() => toggleSection(section.id)}
|
|
337
|
+
/>
|
|
338
|
+
) : null}
|
|
339
|
+
{/* `hidden` rather than not rendering: the group keeps its identity
|
|
340
|
+
across a fold, so `aria-controls` always points at a real element and
|
|
341
|
+
React does not tear down and rebuild every row on a toggle. */}
|
|
342
|
+
<div id={`nav-group-${section.id}`} className="flex flex-col gap-px" hidden={!open}>
|
|
343
|
+
{section.entries.map((entry) =>
|
|
344
|
+
entry.kind === "route" ? (
|
|
345
|
+
<NavItem
|
|
346
|
+
key={entry.key}
|
|
347
|
+
icon={entry.icon}
|
|
348
|
+
label={entry.label}
|
|
349
|
+
narrow={railNarrow}
|
|
350
|
+
active={active === entry.key}
|
|
351
|
+
onPress={guarded(() => navigate(entry.page as never, entry.params ? ({ params: entry.params } as never) : undefined as never))}
|
|
352
|
+
/>
|
|
353
|
+
) : (
|
|
354
|
+
<NavLink
|
|
355
|
+
key={entry.key}
|
|
356
|
+
link={entry.link}
|
|
357
|
+
icon={entry.icon}
|
|
358
|
+
narrow={railNarrow}
|
|
359
|
+
sameTab={opensInSameTab(entry.link.href, entry.link.target, basePath, documentUrl)}
|
|
360
|
+
confirm={confirmNavigation}
|
|
361
|
+
/>
|
|
362
|
+
),
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
})}
|
|
368
|
+
</nav>
|
|
369
|
+
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</aside>
|
|
373
|
+
|
|
374
|
+
{/* `min-w-0`, because a grid column's default `min-width: auto` lets a wide child (a
|
|
375
|
+
table, a long slug) push the column past its track and squeeze the rail. */}
|
|
376
|
+
<div className="min-w-0">
|
|
377
|
+
{/* The app bar. Everything here belongs to the SESSION rather than to the screen, which
|
|
378
|
+
is why it is not in the screen header — and why it survives a narrowed rail, where
|
|
379
|
+
the sign-out and theme controls used to live and no longer fit. Sticky at the top of
|
|
380
|
+
the content column; `page-header.tsx` pins the screen header directly beneath it,
|
|
381
|
+
both off the one height in `chrome.ts`. No rule under it, and the header below has no
|
|
382
|
+
padding above it: the two read as one block of chrome rather than a bar with a
|
|
383
|
+
panel parked beneath it. `bg-surface` is what keeps content from scrolling
|
|
384
|
+
through — the border was never what did that. */}
|
|
385
|
+
<div className={`sticky top-0 z-30 flex ${APP_BAR_H} items-center gap-2 bg-surface px-7`}>
|
|
160
386
|
<Button
|
|
161
387
|
variant="ghost"
|
|
162
388
|
size="sm"
|
|
163
|
-
aria-label={
|
|
164
|
-
|
|
389
|
+
aria-label={railNarrow ? "Expand the sidebar" : "Collapse the sidebar"}
|
|
390
|
+
aria-expanded={!railNarrow}
|
|
391
|
+
className="-ml-2 hidden rounded-md px-2 py-1.5 text-fg-muted hover:text-fg md:inline-flex"
|
|
392
|
+
onPress={() => setRailChoice((narrow) => !narrow)}
|
|
165
393
|
>
|
|
166
|
-
|
|
394
|
+
<RailToggleIcon className="h-[17px] w-[17px]" />
|
|
167
395
|
</Button>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
396
|
+
{/* The trail. The section is a BUTTON while a detail crumb is showing and plain text
|
|
397
|
+
otherwise — a link to the page you are already on is a link that does nothing,
|
|
398
|
+
and the only thing more annoying than no breadcrumb is one that lies about being
|
|
399
|
+
navigable. */}
|
|
400
|
+
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1 text-compact">
|
|
401
|
+
{sectionCrumb ? (
|
|
402
|
+
crumb ? (
|
|
403
|
+
<Button
|
|
404
|
+
variant="ghost"
|
|
405
|
+
size="sm"
|
|
406
|
+
className="max-w-[220px] shrink-0 truncate rounded-md px-2 py-1 text-fg-muted hover:text-fg"
|
|
407
|
+
onPress={guarded(() =>
|
|
408
|
+
navigate(sectionCrumb.page as never, sectionCrumb.params ? ({ params: sectionCrumb.params } as never) : (undefined as never)),
|
|
409
|
+
)}
|
|
410
|
+
>
|
|
411
|
+
{sectionCrumb.label}
|
|
412
|
+
</Button>
|
|
413
|
+
) : (
|
|
414
|
+
<span className="max-w-[220px] truncate px-2 py-1 text-fg-muted">{sectionCrumb.label}</span>
|
|
415
|
+
)
|
|
416
|
+
) : null}
|
|
417
|
+
{crumb ? (
|
|
418
|
+
<>
|
|
419
|
+
{/* Only BETWEEN two crumbs. A screen can publish a detail crumb from a route
|
|
420
|
+
no nav entry matches (a per-content-type deployment's `/pages/:id`), and
|
|
421
|
+
the separator then led the trail with a stray "›". */}
|
|
422
|
+
{sectionCrumb ? <CrumbSeparatorIcon aria-hidden="true" className="h-3 w-3 shrink-0 text-fg-subtle" /> : null}
|
|
423
|
+
{/* `aria-current="page"` so the trailing crumb is announced as where you are,
|
|
424
|
+
not as one more thing to visit. */}
|
|
425
|
+
<span aria-current="page" className="min-w-0 truncate px-1 py-1 font-medium text-fg">
|
|
426
|
+
{crumb}
|
|
427
|
+
</span>
|
|
428
|
+
</>
|
|
429
|
+
) : null}
|
|
430
|
+
</nav>
|
|
431
|
+
<div className="ml-auto pl-4">
|
|
432
|
+
<AccountMenu
|
|
433
|
+
me={me}
|
|
434
|
+
theme={theme}
|
|
435
|
+
onTheme={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
436
|
+
onSettings={guarded(() => navigate("settings"))}
|
|
437
|
+
onSignOut={guarded(reconfigure)}
|
|
438
|
+
/>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
{error ? (
|
|
442
|
+
<Card variant="outlined" padding="none" className="mx-7 mt-2 border-danger px-4 py-2.5">
|
|
443
|
+
<Text size="small" className="text-danger">
|
|
444
|
+
{error}
|
|
445
|
+
</Text>
|
|
446
|
+
</Card>
|
|
447
|
+
) : null}
|
|
448
|
+
<BreadcrumbProvider publish={setCrumb}>
|
|
449
|
+
<Outlet />
|
|
450
|
+
</BreadcrumbProvider>
|
|
451
|
+
</div>
|
|
181
452
|
</div>
|
|
182
453
|
);
|
|
183
454
|
}
|
|
184
455
|
|
|
456
|
+
/** The icon column of a nav row.
|
|
457
|
+
*
|
|
458
|
+
* A collection and a Block Kit page may declare their own icon server-side, which is an
|
|
459
|
+
* arbitrary string (an emoji, in practice). It used to be prepended to the LABEL, where it
|
|
460
|
+
* read as part of the words and wrapped with them; here both cases occupy the same 16px box,
|
|
461
|
+
* so a rail mixing declared emoji with built-in glyphs still has one aligned icon column.
|
|
462
|
+
*/
|
|
463
|
+
function NavIconSlot({ icon }: { icon: NavIcon }): ReactNode {
|
|
464
|
+
if (icon.kind === "emoji") {
|
|
465
|
+
return (
|
|
466
|
+
<span aria-hidden="true" className="flex h-[15px] w-[15px] shrink-0 items-center justify-center text-[13px] leading-none">
|
|
467
|
+
{icon.char}
|
|
468
|
+
</span>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const Glyph = NAV_GLYPHS[icon.name];
|
|
472
|
+
return <Glyph className="h-[15px] w-[15px] shrink-0" />;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The shared shape of a rail row — a full-width ghost rect, icon then label.
|
|
476
|
+
*
|
|
477
|
+
* Three overrides of podoba's `Button`, all for the same reason: it is an ACTION button and
|
|
478
|
+
* this is a list row. `justify-start`, because a centred label gives a column with no left
|
|
479
|
+
* edge to read down (and sits differently from the host-link rows, which are plain anchors);
|
|
480
|
+
* `rounded-md`, because the pill is a shape you aim at and a rail is a shape you scan; and
|
|
481
|
+
* the tighter padding, which is what puts a dozen destinations on screen without scrolling.
|
|
482
|
+
*
|
|
483
|
+
* `truncate` rather than wrapping: a long collection label is recognisable from its first
|
|
484
|
+
* words, and a row that grows to two lines breaks the rhythm of the column it sits in (the
|
|
485
|
+
* `title` carries the full text for the one reader who needs it). */
|
|
486
|
+
const ROW = "flex w-full items-center justify-start gap-2 rounded-md px-2 py-[5px] text-left leading-4";
|
|
487
|
+
|
|
488
|
+
/** One group's heading, and the control that folds it.
|
|
489
|
+
*
|
|
490
|
+
* The chevron is always drawn, not revealed on hover: this rail is read by people who are
|
|
491
|
+
* not power users of it, and an affordance that only exists once you are already pointing at
|
|
492
|
+
* it is one nobody discovers. It points DOWN when open and RIGHT when folded, which is the
|
|
493
|
+
* one rotation every file tree has agreed on.
|
|
494
|
+
*/
|
|
495
|
+
function SectionHeader({ label, open, controls, onPress }: { label: string; open: boolean; controls: string; onPress: () => void }) {
|
|
496
|
+
return (
|
|
497
|
+
<Button
|
|
498
|
+
variant="ghost"
|
|
499
|
+
size="sm"
|
|
500
|
+
aria-expanded={open}
|
|
501
|
+
aria-controls={controls}
|
|
502
|
+
className="flex w-full items-center justify-start gap-2 rounded-md px-2 py-1 text-left text-caption font-medium leading-4 text-fg-subtle hover:text-fg-muted"
|
|
503
|
+
onPress={onPress}
|
|
504
|
+
>
|
|
505
|
+
{/* The chevron sits in a box the width of a row's ICON, so the heading's label starts
|
|
506
|
+
on the same x as the labels it heads. A chevron sized to itself pulls the heading
|
|
507
|
+
7px left of its own group, which reads as a stray indent rather than as a level. */}
|
|
508
|
+
<span aria-hidden="true" className="flex h-[15px] w-[15px] shrink-0 items-center justify-center">
|
|
509
|
+
{open ? <GroupOpenIcon className="h-3 w-3" /> : <GroupFoldedIcon className="h-3 w-3" />}
|
|
510
|
+
</span>
|
|
511
|
+
<span className="min-w-0 truncate">{label}</span>
|
|
512
|
+
</Button>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** One route destination in the rail.
|
|
517
|
+
*
|
|
518
|
+
* `aria-current` alongside the tint: the rail is N mutually-exclusive destinations whose
|
|
519
|
+
* only "you are here" cue is a background, which is invisible to a screen reader and
|
|
520
|
+
* marginal for anyone who cannot see it. The active row also takes `font-medium` — podoba
|
|
521
|
+
* has ONE muted-surface token, so hover and active would otherwise be the same pixel, and
|
|
522
|
+
* "where am I" would vanish under the cursor.
|
|
523
|
+
*/
|
|
524
|
+
function NavItem({ icon, label, narrow, active, onPress }: { icon: NavIcon; label: string; narrow: boolean; active: boolean; onPress: () => void }) {
|
|
525
|
+
return (
|
|
526
|
+
<Button
|
|
527
|
+
variant="ghost"
|
|
528
|
+
size="sm"
|
|
529
|
+
// Narrowed, the label is gone from the screen but not from the accessible name — the
|
|
530
|
+
// row is still "Media", not an unnamed button with a picture in it.
|
|
531
|
+
{...(narrow ? { "aria-label": label } : {})}
|
|
532
|
+
className={`${ROW} ${narrow ? "md:justify-center md:px-0" : ""} ${active ? "bg-surface-muted font-medium text-fg" : "text-fg-muted hover:text-fg"}`}
|
|
533
|
+
{...(active ? { "aria-current": "page" as const } : {})}
|
|
534
|
+
onPress={onPress}
|
|
535
|
+
>
|
|
536
|
+
<NavIconSlot icon={icon} />
|
|
537
|
+
{/* The tooltip rides the SPAN, not the button: react-aria's `Button` filters `title`
|
|
538
|
+
out of the DOM props it forwards, so it would silently never render. It is also the
|
|
539
|
+
only thing naming a narrowed row on hover, so it is not optional there. */}
|
|
540
|
+
<span title={label} className={`min-w-0 truncate ${narrow ? "md:hidden" : ""}`}>{label}</span>
|
|
541
|
+
</Button>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The account cluster in the app bar.
|
|
547
|
+
*
|
|
548
|
+
* podoba's `UserMenu` (a React Aria `Menu`), so the popover, roving focus, typeahead and
|
|
549
|
+
* dismissal are the design system's rather than three more hand-rolled handlers. It carries
|
|
550
|
+
* what the rail's foot used to: the theme, the way out, and now Settings and who you are —
|
|
551
|
+
* which is the half of this that a narrowed rail has no room for at all.
|
|
552
|
+
*/
|
|
553
|
+
function AccountMenu({
|
|
554
|
+
me,
|
|
555
|
+
theme,
|
|
556
|
+
onTheme,
|
|
557
|
+
onSettings,
|
|
558
|
+
onSignOut,
|
|
559
|
+
}: {
|
|
560
|
+
me: Me | null;
|
|
561
|
+
theme: string;
|
|
562
|
+
onTheme: () => void;
|
|
563
|
+
onSettings: () => void;
|
|
564
|
+
onSignOut: () => void;
|
|
565
|
+
}) {
|
|
566
|
+
// The server-resolved identity, which is a username rather than a display name — this app
|
|
567
|
+
// has no profile. Falling back to "account" keeps the avatar's initials from reading as "?"
|
|
568
|
+
// in the window between boot and the `me` call landing.
|
|
569
|
+
const who = me?.userId ?? "account";
|
|
570
|
+
return (
|
|
571
|
+
<UserMenu
|
|
572
|
+
triggerLabel={`Account — ${who}`}
|
|
573
|
+
trigger={
|
|
574
|
+
<>
|
|
575
|
+
<Avatar name={who} size="sm" ring={false} />
|
|
576
|
+
<span className="max-w-[180px] truncate text-compact text-fg-muted max-[560px]:hidden">{who}</span>
|
|
577
|
+
</>
|
|
578
|
+
}
|
|
579
|
+
onAction={(key) => {
|
|
580
|
+
if (key === "theme") onTheme();
|
|
581
|
+
else if (key === "settings") onSettings();
|
|
582
|
+
else if (key === "signout") onSignOut();
|
|
583
|
+
}}
|
|
584
|
+
>
|
|
585
|
+
<UserMenuItem id="theme" className="gap-2.5">
|
|
586
|
+
{theme === "dark" ? <LightThemeIcon className="h-[15px] w-[15px]" /> : <DarkThemeIcon className="h-[15px] w-[15px]" />}
|
|
587
|
+
{theme === "dark" ? "Light theme" : "Dark theme"}
|
|
588
|
+
</UserMenuItem>
|
|
589
|
+
<UserMenuItem id="settings" className="gap-2.5">
|
|
590
|
+
<SettingsIcon className="h-[15px] w-[15px]" />
|
|
591
|
+
Settings
|
|
592
|
+
</UserMenuItem>
|
|
593
|
+
<UserMenuItem id="signout" className="gap-2.5">
|
|
594
|
+
<SignOutIcon className="h-[15px] w-[15px]" />
|
|
595
|
+
Sign out
|
|
596
|
+
</UserMenuItem>
|
|
597
|
+
</UserMenu>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
185
601
|
/** One host-configured link to a companion tool.
|
|
186
602
|
*
|
|
187
603
|
* `rel="noreferrer"` is on BOTH branches. `noopener` is genuinely moot in the same tab (no
|
|
188
604
|
* new browsing context is created, so there is no `window.opener` to sever) but `noreferrer`
|
|
189
|
-
* is not: without it a click from `/
|
|
605
|
+
* is not: without it a click from `/__admin/pages/<id>` hands that full url to the
|
|
190
606
|
* destination as `Referer`, and `_self` is honoured for cross-origin destinations.
|
|
191
607
|
*
|
|
192
608
|
* The key pairs href with label, because two entries may legitimately point at the same href
|
|
193
609
|
* and differ only in label or target — keyed on href alone React reconciles them together
|
|
194
610
|
* and the rendered label can end up on the other one's anchor.
|
|
195
611
|
*/
|
|
196
|
-
function NavLink({ link, sameTab, confirm }: { link: ExtraNavLink; sameTab: boolean; confirm: () => boolean }) {
|
|
612
|
+
function NavLink({ link, icon, narrow, sameTab, confirm }: { link: ExtraNavLink; icon: NavIcon; narrow: boolean; sameTab: boolean; confirm: () => boolean }) {
|
|
197
613
|
return (
|
|
198
614
|
<a
|
|
199
615
|
href={link.href}
|
|
@@ -201,9 +617,11 @@ function NavLink({ link, sameTab, confirm }: { link: ExtraNavLink; sameTab: bool
|
|
|
201
617
|
// Only the same-tab case unloads this document, so only it consults the guard.
|
|
202
618
|
onClick={sameTab ? (e) => { if (!confirm()) e.preventDefault(); } : undefined}
|
|
203
619
|
{...(sameTab ? {} : { target: "_blank" })}
|
|
204
|
-
|
|
620
|
+
{...(narrow ? { "aria-label": link.label } : {})}
|
|
621
|
+
className={`${ROW} ${narrow ? "md:justify-center md:px-0" : ""} text-compact text-fg-muted no-underline transition-colors hover:bg-surface-muted hover:text-fg`}
|
|
205
622
|
>
|
|
206
|
-
{
|
|
623
|
+
<NavIconSlot icon={icon} />
|
|
624
|
+
<span title={link.label} className={`min-w-0 truncate ${narrow ? "md:hidden" : ""}`}>{link.label}</span>
|
|
207
625
|
</a>
|
|
208
626
|
);
|
|
209
627
|
}
|