@pramen/cms-editor 0.0.59 → 0.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/nav.ts ADDED
@@ -0,0 +1,134 @@
1
+ // The primary nav, as data.
2
+ //
3
+ // It used to be a fixed sequence written out in JSX: Pages, collections, Media, Users,
4
+ // Settings, then `extraNav`. That order was the whole reason a project-specific section
5
+ // could not be part of the admin — `extraNav` renders dead LAST and (by default, and for
6
+ // good reason) opens a new tab, so anything not backed by a `collection()` was structurally
7
+ // the final item and structurally a different app.
8
+ //
9
+ // So the nav is built as a list of entries carrying an ORDER, and sorted. `NAV_ORDER` gives
10
+ // the built-ins positions spaced 100 apart, a collection declares `navOrder` server-side,
11
+ // and a host `extraNav` link may declare one too — which is what lets a section sit between
12
+ // Pages and Media instead of after Settings.
13
+ //
14
+ // Split out of `_layout.tsx` so the ordering rule is testable without a DOM: the layout
15
+ // turns entries into buttons, this decides what they are and what order they come in.
16
+
17
+ import type { BuzolaPageMap } from "@buzola/router";
18
+ import { NAV_ORDER, type AdminPageMeta, type CmsCapabilities, type CollectionMeta, type ContentType } from "./types";
19
+
20
+ /** A buzola page id. Typed off the generated page map, so a nav entry naming a route that
21
+ * does not exist is a compile error rather than a tab that navigates nowhere. */
22
+ export type NavPage = keyof BuzolaPageMap;
23
+
24
+ /** A host-configured link to a companion tool. */
25
+ export interface ExtraNavLink {
26
+ label: string;
27
+ href: string;
28
+ target?: "_blank" | "_self";
29
+ /** Where it sits — see {@link NAV_ORDER}. Defaults to `NAV_ORDER.extra` (last), which is
30
+ * where every `extraNav` link rendered before this existed. */
31
+ order?: number;
32
+ }
33
+
34
+ /** One entry in the primary nav.
35
+ *
36
+ * `kind` is what the layout switches on to render it; everything else here is the data it
37
+ * needs. Keeping the ROUTE out of this module is deliberate — `page`/`params` name a
38
+ * buzola page, and the layout is where navigation (and the unsaved-changes guard it runs
39
+ * through) belongs. */
40
+ 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 };
43
+
44
+ /** What the nav is built from. All of it is already in the app context; passing it in keeps
45
+ * this a pure function of the session's facts. */
46
+ export interface NavInput {
47
+ collections: CollectionMeta[];
48
+ /** Block Kit pages this caller may open — already role-filtered by the server. */
49
+ adminPages: AdminPageMeta[];
50
+ contentTypes: ContentType[] | null;
51
+ cms: CmsCapabilities;
52
+ /** Deployment hides the block/page builder entirely (`hidePages`). */
53
+ hidePages: boolean;
54
+ /** One tab per content type rather than a single pooled "Pages" tab. */
55
+ splitByType: boolean;
56
+ isAdmin: boolean;
57
+ extraNav: ExtraNavLink[];
58
+ }
59
+
60
+ /**
61
+ * Build the primary nav, in order.
62
+ *
63
+ * The sort is STABLE (`Array.prototype.sort` is, per spec, since ES2019), which is what
64
+ * carries the two groupings that have no numeric expression: content-type tabs stay in the
65
+ * server's order among themselves, and so do collections that share a `navOrder`. Ties are
66
+ * therefore declaration order, which is the only answer a host can predict.
67
+ */
68
+ export function buildNav(input: NavInput): NavEntry[] {
69
+ const { collections, adminPages, contentTypes, cms, hidePages, splitByType, isAdmin, extraNav } = input;
70
+ const entries: NavEntry[] = [];
71
+
72
+ if (!hidePages) {
73
+ if (splitByType) {
74
+ // One tab per content type: a CMS holding pages AND articles pooled them into a
75
+ // single list where the only thing telling a landing page from a news item was the
76
+ // slug. The label is the type's own `name`, so a host that wants a plural tab writes
77
+ // one.
78
+ 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 } });
80
+ }
81
+ } else {
82
+ entries.push({ kind: "route", key: "pages", order: NAV_ORDER.pages, label: "Pages", page: "home" });
83
+ }
84
+ }
85
+
86
+ for (const c of collections) {
87
+ entries.push({
88
+ kind: "route",
89
+ key: `col:${c.slug}`,
90
+ order: c.navOrder ?? NAV_ORDER.collections,
91
+ label: `${c.icon ? `${c.icon} ` : ""}${c.pluralLabel}`,
92
+ page: "collection",
93
+ params: { slug: c.slug },
94
+ });
95
+ }
96
+
97
+ entries.push({ kind: "route", key: "media", order: NAV_ORDER.media, label: "Media", page: "media" });
98
+
99
+ if (cms.siteFurniture) {
100
+ entries.push({ kind: "route", key: "menus", order: NAV_ORDER.menus, label: "Menus", page: "menus" });
101
+ // Taxonomies classify PAGES — `cms_page_terms` links a term to a page and to nothing
102
+ // else — so a collections-only deployment has nothing to classify and the section would
103
+ // 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" });
107
+ }
108
+
109
+ // A project's own screens, INSIDE the chrome and at a position they choose. This is the
110
+ // whole difference from `extraNav`, which renders after Settings and opens a new tab — so
111
+ // the odd 10% of a client site was a separate deployment that looked nothing like the
112
+ // admin it hung off.
113
+ for (const p of adminPages) {
114
+ entries.push({ kind: "route", key: `app:${p.slug}`, order: p.navOrder ?? NAV_ORDER.adminPages, label: `${p.icon ? `${p.icon} ` : ""}${p.label}`, page: "admin-page", params: { slug: p.slug } });
115
+ }
116
+
117
+ // Authoring the SCHEMA, not content — so it is gated on `canEdit` (every handler behind
118
+ // it is editor-only) and hidden where there is no block/page builder to define types for.
119
+ if (!hidePages && cms.canEdit) {
120
+ entries.push({ kind: "route", key: "types", order: NAV_ORDER.types, label: "Types", page: "schema" });
121
+ }
122
+
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" });
125
+
126
+ for (const link of extraNav) {
127
+ // Keyed on href AND label: two entries may legitimately point at the same href and
128
+ // differ only in label or target, and keyed on href alone React reconciles them
129
+ // 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 });
131
+ }
132
+
133
+ return entries.sort((a, b) => a.order - b.order);
134
+ }
@@ -9,6 +9,7 @@ import { useApp } from "../app-context";
9
9
  import { BRAND } from "../brand";
10
10
  import { pagesHidden, splitsByType } from "../components";
11
11
  import { opensInSameTab } from "../mount";
12
+ import { buildNav, type ExtraNavLink } from "../nav";
12
13
 
13
14
  const THEME_KEY = "pramen.cms.theme";
14
15
 
@@ -35,7 +36,7 @@ export function segmentAt(pathname: string, prefix: string): string | undefined
35
36
  }
36
37
 
37
38
  export default function RootLayout() {
38
- const { isAdmin, collections, contentTypes, cms, error, reconfigure, confirmNavigation } = useApp();
39
+ const { isAdmin, collections, adminPages, contentTypes, cms, error, reconfigure, confirmNavigation } = useApp();
39
40
  const navigate = useNavigate();
40
41
  const { pathname } = useRoute();
41
42
 
@@ -60,6 +61,8 @@ export default function RootLayout() {
60
61
  const collectionSlug = segmentAt(pathname, "/collections/");
61
62
  // …and the active content type, under /types/:slug.
62
63
  const typeSlug = segmentAt(pathname, "/types/");
64
+ // …and the active Block Kit page, under /apps/:slug.
65
+ const appSlug = segmentAt(pathname, "/apps/");
63
66
 
64
67
  // "Pages" stays lit while editing a page (/pages/:id) too — but only on a deployment that
65
68
  // still HAS a pooled Pages tab. Split by type, the page editor lights nothing: the route
@@ -67,8 +70,16 @@ export default function RootLayout() {
67
70
  // without fetching the page the editor is already fetching.
68
71
  const active = collectionSlug ? `col:${collectionSlug}`
69
72
  : typeSlug ? `type:${typeSlug}`
73
+ : appSlug ? `app:${appSlug}`
70
74
  : pathname.startsWith("/pages") || pathname === "/" ? "pages"
71
75
  : pathname.startsWith("/media") ? "media"
76
+ // `/schema` rather than `/types`, because `/types/:slug` is already one content type's
77
+ // PAGE LIST — a different thing entirely, and the tab keyed `type:<slug>` above.
78
+ : pathname.startsWith("/schema") ? "types"
79
+ : pathname.startsWith("/menus") ? "menus"
80
+ : pathname.startsWith("/taxonomies") ? "taxonomies"
81
+ : pathname.startsWith("/widgets") ? "widgets"
82
+ : pathname.startsWith("/redirects") ? "redirects"
72
83
  : pathname.startsWith("/users") ? "users"
73
84
  : pathname.startsWith("/settings") ? "settings"
74
85
  : "";
@@ -82,11 +93,12 @@ export default function RootLayout() {
82
93
  });
83
94
 
84
95
  // Host-configured links to companion tools (e.g. a curation page), from /config.js.
85
- const extraNav = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.extraNav ?? [] : [];
96
+ const extraNav: ExtraNavLink[] = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.extraNav ?? [] : [];
86
97
  // Collections-only deployments hide the block/page builder entirely.
87
98
  const hidePages = pagesHidden();
88
99
  // Same rule as the landing redirect and the page editor's back target — see `splitsByType`.
89
100
  const splitByType = splitsByType(contentTypes, cms, hidePages);
101
+ const nav = buildNav({ collections, adminPages, contentTypes, cms, hidePages, splitByType, isAdmin, extraNav });
90
102
 
91
103
  // See the extraNav comment below. The rules live in `mount.ts` beside the containment they
92
104
  // depend on; what this supplies is the URL the BROWSER will resolve a relative href
@@ -114,63 +126,33 @@ export default function RootLayout() {
114
126
  </button>
115
127
  </Topbar.Brand>
116
128
  <Topbar.Nav aria-label="Primary">
117
- {/* One tab per content type once a deployment declares more than one: a CMS holding
118
- pages AND articles pooled them into a single "Pages" list where the only thing
119
- distinguishing a landing page from a news item was the slug. A single type keeps
120
- the plain "Pages" tab there is nothing there to separate, and splitting it
121
- would put the deployment's own type name where a generic label reads better.
122
- The label is the type's `name`, so a host that wants a plural tab writes one. */}
123
- {hidePages ? null : splitByType ? (
124
- (contentTypes ?? []).map((t) => (
129
+ {/* Order comes from `buildNav`, not from the sequence written here see nav.ts.
130
+ A section can therefore sit BETWEEN two built-ins (a collection declaring
131
+ `navOrder`, a host link declaring `order`) rather than only after Settings,
132
+ which is what made a project-specific section structurally a bolted-on second
133
+ app. Rendering stays here because navigation belongs to the layout: every
134
+ route entry goes through the unsaved-changes guard, and an `extraNav` link
135
+ that leaves the document takes the same guard. */}
136
+ {nav.map((entry) =>
137
+ entry.kind === "route" ? (
125
138
  <Button
126
- key={t.slug}
139
+ key={entry.key}
127
140
  variant="ghost"
128
141
  size="sm"
129
- {...tabProps(`type:${t.slug}`)}
130
- onPress={guarded(() => navigate("type", { params: { slug: t.slug } }))}
142
+ {...tabProps(entry.key)}
143
+ onPress={guarded(() => navigate(entry.page as never, entry.params ? ({ params: entry.params } as never) : undefined as never))}
131
144
  >
132
- {t.name}
145
+ {entry.label}
133
146
  </Button>
134
- ))
135
- ) : (
136
- <Button variant="ghost" size="sm" {...tabProps("pages")} onPress={guarded(() => navigate("home"))}>
137
- Pages
138
- </Button>
147
+ ) : (
148
+ <NavLink
149
+ key={entry.key}
150
+ link={entry.link}
151
+ sameTab={opensInSameTab(entry.link.href, entry.link.target, basePath, documentUrl)}
152
+ confirm={confirmNavigation}
153
+ />
154
+ ),
139
155
  )}
140
- {collections.map((c) => (
141
- <Button key={c.slug} variant="ghost" size="sm" {...tabProps(`col:${c.slug}`)} onPress={guarded(() => navigate("collection", { params: { slug: c.slug } }))}>
142
- {c.icon ? `${c.icon} ` : ""}
143
- {c.pluralLabel}
144
- </Button>
145
- ))}
146
- <Button variant="ghost" size="sm" {...tabProps("media")} onPress={guarded(() => navigate("media"))}>
147
- Media
148
- </Button>
149
- {isAdmin ? (
150
- <Button variant="ghost" size="sm" {...tabProps("users")} onPress={guarded(() => navigate("users"))}>
151
- Users
152
- </Button>
153
- ) : null}
154
- <Button variant="ghost" size="sm" {...tabProps("settings")} onPress={guarded(() => navigate("settings"))}>
155
- Settings
156
- </Button>
157
- {extraNav.map((l) => (
158
- // Defaults to a new tab: a companion tool is normally a separate deployment, and
159
- // `_404.tsx` registers the catch-all `/:__notFound+`, so the router matches every
160
- // SAME-ORIGIN path — a same-tab click would land on the in-app 404 rather than the
161
- // tool. `target: "_self"` asks for the co-hosted case, and is honoured only where
162
- // the router provably will not claim the url (`opensInSameTab` in mount.ts):
163
- //
164
- // - cross-origin: always, mounted or not. The catch-all cannot reach another
165
- // origin, so this is the ONE case that also works at the origin root.
166
- // - same-origin: only outside the mount prefix, where `scopeToBasePath` leaves
167
- // the navigation to the browser. At the root there is no outside, so never.
168
- //
169
- // Anything else degrades to the new tab rather than stranding the user on a 404.
170
- // (When @buzola/router moves past ^0.0.12 here, `router.leaveApp(href)` releases
171
- // one navigation to the browser and makes same-origin `_self` work unmounted too.)
172
- <NavLink key={`${l.href}|${l.label}`} link={l} sameTab={opensInSameTab(l.href, l.target, basePath, documentUrl)} confirm={confirmNavigation} />
173
- ))}
174
156
  </Topbar.Nav>
175
157
  <Topbar.Actions>
176
158
  {/* The tenant is deployment configuration, not something an editor acts on —
@@ -211,7 +193,7 @@ export default function RootLayout() {
211
193
  * and differ only in label or target — keyed on href alone React reconciles them together
212
194
  * and the rendered label can end up on the other one's anchor.
213
195
  */
214
- function NavLink({ link, sameTab, confirm }: { link: { label: string; href: string; target?: string }; sameTab: boolean; confirm: () => boolean }) {
196
+ function NavLink({ link, sameTab, confirm }: { link: ExtraNavLink; sameTab: boolean; confirm: () => boolean }) {
215
197
  return (
216
198
  <a
217
199
  href={link.href}
@@ -0,0 +1,36 @@
1
+ // A custom admin page (`/apps/:slug`) — Block Kit, rendered inside the editor's own chrome.
2
+ //
3
+ // Routed BY THE EDITOR, which is the point: an `extraNav` link has to open a new tab,
4
+ // because `_404.tsx` registers the catch-all `/:__notFound+` and a same-tab click on an
5
+ // unmounted editor lands on the in-app 404. A registered page has a real route, so it is
6
+ // part of the admin rather than a link out of it.
7
+
8
+ import { createPage, useNavigate } from "@buzola/router";
9
+ import { useApp } from "../app-context";
10
+ import { AdminPageView } from "../blockkit";
11
+ import { Notice } from "../components";
12
+ import { Button } from "@podoba/react";
13
+
14
+ export default createPage()
15
+ .params({ slug: "string" })
16
+ .route("/apps/:slug")
17
+ .render(function AdminPageRoute({ params }) {
18
+ const { api, adminPages, setError } = useApp();
19
+ const navigate = useNavigate();
20
+ const def = adminPages.find((p) => p.slug === params.slug);
21
+
22
+ // `listAdminPages` is role-FILTERED server-side, so an absent slug means either "still
23
+ // loading" or "not yours / not registered" — and the two are told apart by whether the
24
+ // list has arrived at all, exactly as the collection route does it.
25
+ if (!def) {
26
+ return (
27
+ <Notice action={adminPages.length > 0 ? <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Pages</Button> : undefined}>
28
+ {adminPages.length === 0 ? "Loading…" : `Unknown page: ${params.slug}`}
29
+ </Notice>
30
+ );
31
+ }
32
+ // Keyed on the slug so switching between two pages REMOUNTS the view: buzola renders the
33
+ // same component instance across a params-only change, and the blocks, the form values
34
+ // and any toast all belong to one page.
35
+ return <AdminPageView api={api} key={def.slug} slug={def.slug} label={def.label} onError={setError} />;
36
+ });
@@ -0,0 +1,29 @@
1
+ // One block type's builder (`/schema/blocks/:slug`); `new` creates one.
2
+
3
+ import { createPage, useNavigate } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { BlockTypeEditor } from "../schema-builder";
7
+
8
+ export default createPage()
9
+ .params({ slug: "string" })
10
+ .route("/schema/blocks/:slug")
11
+ .render(function BlockTypeRoute({ params }) {
12
+ const { api, cms, setError } = useApp();
13
+ const navigate = useNavigate();
14
+ if (!cms.canEdit) return <Notice>Authoring types needs an editor role.</Notice>;
15
+ return (
16
+ <BlockTypeEditor
17
+ api={api}
18
+ codeDefinedTypes={cms.codeDefinedTypes}
19
+ // Keyed on the slug so switching between two types REMOUNTS the form: buzola renders
20
+ // the same component instance across a params-only change, and the draft state
21
+ // belongs to one type.
22
+ key={params.slug}
23
+ slug={params.slug}
24
+ onSaved={(slug) => navigate("block-type", { params: { slug } })}
25
+ onBack={() => navigate("schema")}
26
+ onError={setError}
27
+ />
28
+ );
29
+ });
@@ -0,0 +1,32 @@
1
+ // One content type's builder (`/schema/content/:slug`); `new` creates one.
2
+
3
+ import { createPage, useNavigate } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { ContentTypeEditor } from "../schema-builder";
7
+
8
+ export default createPage()
9
+ .params({ slug: "string" })
10
+ .route("/schema/content/:slug")
11
+ .render(function ContentTypeBuilderRoute({ params }) {
12
+ const { api, cms, setError, refreshContentTypes } = useApp();
13
+ const navigate = useNavigate();
14
+ if (!cms.canEdit) return <Notice>Authoring types needs an editor role.</Notice>;
15
+ return (
16
+ <ContentTypeEditor
17
+ api={api}
18
+ codeDefinedTypes={cms.codeDefinedTypes}
19
+ key={params.slug}
20
+ slug={params.slug}
21
+ onSaved={(slug) => {
22
+ // The nav's per-type tabs come from `listContentTypes`, which the app context
23
+ // fetched once at boot — without this a type created here has no tab until the
24
+ // next full reload, which reads as "it didn't save".
25
+ refreshContentTypes();
26
+ navigate("content-type", { params: { slug } });
27
+ }}
28
+ onBack={() => navigate("schema")}
29
+ onError={setError}
30
+ />
31
+ );
32
+ });
@@ -0,0 +1,30 @@
1
+ // One menu's tree editor (`/menus/:name`).
2
+
3
+ import { createPage, useNavigate } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { MenuEditor } from "../furniture";
7
+
8
+ export default createPage()
9
+ .params({ name: "string" })
10
+ .route("/menus/:name")
11
+ .render(function MenuRoute({ params }) {
12
+ const { api, cms, collections, setError } = useApp();
13
+ const navigate = useNavigate();
14
+ // The nav hides these on a server without the handlers, but a BOOKMARK does not —
15
+ // without this the screen mounts fully interactive and every call 404s. The
16
+ // `/schema` routes already gated on their own capability; these did not.
17
+ if (!cms.siteFurniture) return <Notice>This deployment's CMS does not provide site furniture.</Notice>;
18
+ return (
19
+ <MenuEditor
20
+ api={api}
21
+ key={params.name}
22
+ name={params.name}
23
+ canEdit={cms.canEdit}
24
+ collections={collections}
25
+ onBack={() => navigate("menus")}
26
+ onDeleted={() => navigate("menus")}
27
+ onError={setError}
28
+ />
29
+ );
30
+ });
@@ -0,0 +1,18 @@
1
+ // The menus list (`/menus`).
2
+
3
+ import { createPage, useNavigate } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { MenusView } from "../furniture";
7
+
8
+ export default createPage()
9
+ .route("/menus")
10
+ .render(function MenusRoute() {
11
+ const { api, cms, setError } = useApp();
12
+ const navigate = useNavigate();
13
+ // The nav hides these on a server without the handlers, but a BOOKMARK does not —
14
+ // without this the screen mounts fully interactive and every call 404s. The
15
+ // `/schema` routes already gated on their own capability; these did not.
16
+ if (!cms.siteFurniture) return <Notice>This deployment's CMS does not provide site furniture.</Notice>;
17
+ return <MenusView api={api} canEdit={cms.canEdit} onOpen={(name) => navigate("menu", { params: { name } })} onError={setError} />;
18
+ });
@@ -52,7 +52,7 @@ export default createPage()
52
52
  // match. Rendering one tab under another tab's address means a refresh, a Back, or a
53
53
  // shared link all disagree with what is on screen; `setTab` already replaces without
54
54
  // adding a history entry, so reconciling costs nothing.
55
- const shown = visibleTabs(cms.multilingual);
55
+ const shown = visibleTabs(cms.multilingual, cms.siteFurniture);
56
56
  const tab: InspectorTab = shown.includes(params.tab as InspectorTab) ? (params.tab as InspectorTab) : "settings";
57
57
  const setTab = (t: InspectorTab) => navigate("page", { params: { pageId: params.pageId, tab: t }, replace: true });
58
58
  useEffect(() => {
@@ -0,0 +1,17 @@
1
+ // Redirects (`/redirects`) — one screen, because a redirect is a row.
2
+
3
+ import { createPage } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { RedirectsView } from "../furniture";
7
+
8
+ export default createPage()
9
+ .route("/redirects")
10
+ .render(function RedirectsRoute() {
11
+ const { api, cms, setError } = useApp();
12
+ // The nav hides these on a server without the handlers, but a BOOKMARK does not —
13
+ // without this the screen mounts fully interactive and every call 404s. The
14
+ // `/schema` routes already gated on their own capability; these did not.
15
+ if (!cms.siteFurniture) return <Notice>This deployment's CMS does not provide site furniture.</Notice>;
16
+ return <RedirectsView api={api} canEdit={cms.canEdit} onError={setError} />;
17
+ });
@@ -0,0 +1,34 @@
1
+ // The types overview (`/schema`) — block types and content types, and the way into their
2
+ // builders.
3
+ //
4
+ // `/schema`, not `/types`: `/types/:slug` is already ONE content type's page list (the tab
5
+ // bar's per-type route), and a parent path meaning something entirely different from its
6
+ // children is the kind of collision that reads as a bug forever after. The nav label stays
7
+ // "Types", because that is what an editor is looking for.
8
+
9
+ import { createPage, useNavigate } from "@buzola/router";
10
+ import { useApp } from "../app-context";
11
+ import { Notice } from "../components";
12
+ import { TypesOverview } from "../schema-builder";
13
+
14
+ export default createPage()
15
+ .route("/schema")
16
+ .render(function SchemaRoute() {
17
+ const { api, cms, setError } = useApp();
18
+ const navigate = useNavigate();
19
+
20
+ // Every handler behind this screen is editor-gated, so a reviewer-only session would
21
+ // get a builder where each save 403s. The nav hides the tab for the same reason; this
22
+ // is the deep-link half of it.
23
+ if (!cms.canEdit) return <Notice>Authoring types needs an editor role.</Notice>;
24
+
25
+ return (
26
+ <TypesOverview
27
+ api={api}
28
+ codeDefinedTypes={cms.codeDefinedTypes}
29
+ onOpenBlockType={(slug) => navigate("block-type", { params: { slug } })}
30
+ onOpenContentType={(slug) => navigate("content-type", { params: { slug } })}
31
+ onError={setError}
32
+ />
33
+ );
34
+ });
@@ -0,0 +1,18 @@
1
+ // The vocabularies list (`/taxonomies`).
2
+
3
+ import { createPage, useNavigate } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { TaxonomiesView } from "../furniture";
7
+
8
+ export default createPage()
9
+ .route("/taxonomies")
10
+ .render(function TaxonomiesRoute() {
11
+ const { api, cms, setError } = useApp();
12
+ const navigate = useNavigate();
13
+ // The nav hides these on a server without the handlers, but a BOOKMARK does not —
14
+ // without this the screen mounts fully interactive and every call 404s. The
15
+ // `/schema` routes already gated on their own capability; these did not.
16
+ if (!cms.siteFurniture) return <Notice>This deployment's CMS does not provide site furniture.</Notice>;
17
+ return <TaxonomiesView api={api} canEdit={cms.canEdit} onOpen={(slug) => navigate("taxonomy", { params: { slug } })} onError={setError} />;
18
+ });
@@ -0,0 +1,29 @@
1
+ // One vocabulary's terms (`/taxonomies/:slug`).
2
+
3
+ import { createPage, useNavigate } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { TaxonomyEditor } from "../furniture";
7
+
8
+ export default createPage()
9
+ .params({ slug: "string" })
10
+ .route("/taxonomies/:slug")
11
+ .render(function TaxonomyRoute({ params }) {
12
+ const { api, cms, setError } = useApp();
13
+ const navigate = useNavigate();
14
+ // The nav hides these on a server without the handlers, but a BOOKMARK does not —
15
+ // without this the screen mounts fully interactive and every call 404s. The
16
+ // `/schema` routes already gated on their own capability; these did not.
17
+ if (!cms.siteFurniture) return <Notice>This deployment's CMS does not provide site furniture.</Notice>;
18
+ return (
19
+ <TaxonomyEditor
20
+ api={api}
21
+ key={params.slug}
22
+ slug={params.slug}
23
+ canEdit={cms.canEdit}
24
+ onBack={() => navigate("taxonomies")}
25
+ onDeleted={() => navigate("taxonomies")}
26
+ onError={setError}
27
+ />
28
+ );
29
+ });
@@ -0,0 +1,29 @@
1
+ // One widget area's contents (`/widgets/:name`).
2
+
3
+ import { createPage, useNavigate } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { WidgetAreaEditor } from "../furniture";
7
+
8
+ export default createPage()
9
+ .params({ name: "string" })
10
+ .route("/widgets/:name")
11
+ .render(function WidgetAreaRoute({ params }) {
12
+ const { api, cms, setError } = useApp();
13
+ const navigate = useNavigate();
14
+ // The nav hides these on a server without the handlers, but a BOOKMARK does not —
15
+ // without this the screen mounts fully interactive and every call 404s. The
16
+ // `/schema` routes already gated on their own capability; these did not.
17
+ if (!cms.siteFurniture) return <Notice>This deployment's CMS does not provide site furniture.</Notice>;
18
+ return (
19
+ <WidgetAreaEditor
20
+ api={api}
21
+ key={params.name}
22
+ name={params.name}
23
+ canEdit={cms.canEdit}
24
+ onBack={() => navigate("widgets")}
25
+ onDeleted={() => navigate("widgets")}
26
+ onError={setError}
27
+ />
28
+ );
29
+ });
@@ -0,0 +1,18 @@
1
+ // The widget-areas list (`/widgets`).
2
+
3
+ import { createPage, useNavigate } from "@buzola/router";
4
+ import { useApp } from "../app-context";
5
+ import { Notice } from "../components";
6
+ import { WidgetAreasView } from "../furniture";
7
+
8
+ export default createPage()
9
+ .route("/widgets")
10
+ .render(function WidgetsRoute() {
11
+ const { api, cms, setError } = useApp();
12
+ const navigate = useNavigate();
13
+ // The nav hides these on a server without the handlers, but a BOOKMARK does not —
14
+ // without this the screen mounts fully interactive and every call 404s. The
15
+ // `/schema` routes already gated on their own capability; these did not.
16
+ if (!cms.siteFurniture) return <Notice>This deployment's CMS does not provide site furniture.</Notice>;
17
+ return <WidgetAreasView api={api} canEdit={cms.canEdit} onOpen={(name) => navigate("widget-area", { params: { name } })} onError={setError} />;
18
+ });