@pramen/cms-editor 0.0.59 → 0.0.61
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 +8 -0
- package/dist/editor.css +1 -1
- package/dist/editor.js +166 -163
- package/package.json +1 -1
- package/src/api.ts +80 -2
- package/src/app-context.tsx +42 -3
- package/src/blockkit.tsx +410 -0
- package/src/buzola.gen.ts +117 -23
- package/src/chrome.ts +16 -0
- package/src/components.tsx +102 -10
- package/src/fields.tsx +257 -2
- package/src/furniture.tsx +924 -0
- package/src/nav.ts +134 -0
- package/src/routes/_layout.tsx +36 -54
- package/src/routes/admin-page.tsx +36 -0
- package/src/routes/block-type.tsx +29 -0
- package/src/routes/content-type.tsx +32 -0
- package/src/routes/menu.tsx +30 -0
- package/src/routes/menus.tsx +18 -0
- package/src/routes/page.tsx +1 -1
- package/src/routes/redirects.tsx +17 -0
- package/src/routes/schema.tsx +34 -0
- package/src/routes/taxonomies.tsx +18 -0
- package/src/routes/taxonomy.tsx +29 -0
- package/src/routes/widget-area.tsx +29 -0
- package/src/routes/widgets.tsx +18 -0
- package/src/schema-builder.tsx +944 -0
- package/src/types.ts +237 -1
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
|
+
}
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -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
|
-
{/*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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={
|
|
139
|
+
key={entry.key}
|
|
127
140
|
variant="ghost"
|
|
128
141
|
size="sm"
|
|
129
|
-
{...tabProps(
|
|
130
|
-
onPress={guarded(() => navigate(
|
|
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
|
-
{
|
|
145
|
+
{entry.label}
|
|
133
146
|
</Button>
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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:
|
|
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
|
+
});
|
package/src/routes/page.tsx
CHANGED
|
@@ -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
|
+
});
|