@pramen/cms-editor 0.0.53 → 0.0.55
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 +17 -1
- package/dist/editor.js +115 -115
- package/package.json +1 -1
- package/src/api.ts +4 -1
- package/src/app-context.tsx +11 -2
- package/src/buzola.gen.ts +13 -4
- package/src/components.tsx +16 -8
- package/src/mount.ts +73 -13
- package/src/routes/_layout.tsx +81 -23
- package/src/routes/home.tsx +12 -4
- package/src/routes/type.tsx +56 -0
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -132,7 +132,10 @@ export class Api {
|
|
|
132
132
|
listBlockTypes = () => this.call<BlockType[]>("listBlockTypes");
|
|
133
133
|
listContentTypes = () => this.call<ContentType[]>("listContentTypes");
|
|
134
134
|
getContentType = (id: string) => this.call<ContentType | null>("getContentType", { id });
|
|
135
|
-
|
|
135
|
+
/** All pages, or just one content type's (by SLUG). The server does the filtering — see
|
|
136
|
+
* `listPages` in @pramen/cms; it caps at 100 rows, so narrowing client-side would drop the
|
|
137
|
+
* tail of every type on a busy deployment. */
|
|
138
|
+
listPages = (contentType?: string) => this.call<Page[]>("listPages", contentType ? { contentType } : undefined);
|
|
136
139
|
getPagePreview = (slug: string, locale?: string) => this.call<AssembledPage>("getPage", { slug, locale, preview: true });
|
|
137
140
|
listPageAudit = (pageId: string) => this.call<AuditEntry[]>("listPageAudit", { pageId });
|
|
138
141
|
|
package/src/app-context.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { createContext, use, useCallback, useEffect, useMemo, useRef, useState }
|
|
|
8
8
|
import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
|
|
9
9
|
import { BRAND, SETUP_TITLE, type BrandConfig } from "./brand";
|
|
10
10
|
import { readBackend, type BackendHost } from "./mount";
|
|
11
|
-
import { DEFAULT_CAPABILITIES, type CmsCapabilities, type CollectionMeta, type JsonValue } from "./types";
|
|
11
|
+
import { DEFAULT_CAPABILITIES, type CmsCapabilities, type CollectionMeta, type ContentType, type JsonValue } from "./types";
|
|
12
12
|
|
|
13
13
|
declare global {
|
|
14
14
|
interface Window {
|
|
@@ -27,7 +27,7 @@ declare global {
|
|
|
27
27
|
hidePages?: boolean;
|
|
28
28
|
/** Extra top-nav links to companion tools the host serves (e.g. a curation page).
|
|
29
29
|
* Rendered as plain external `<a>` links after the built-in tabs. */
|
|
30
|
-
extraNav?: { label: string; href: string }[];
|
|
30
|
+
extraNav?: { label: string; href: string; target?: "_blank" | "_self" }[];
|
|
31
31
|
/** The wordmark in the topbar, on the Setup screen, and in the browser tab.
|
|
32
32
|
*
|
|
33
33
|
* This editor ships as a package an agency deploys FOR ITS CLIENT, so the default
|
|
@@ -72,6 +72,10 @@ interface AppContextValue {
|
|
|
72
72
|
/** Collections registered on the server (from `listCollections`) — drives the nav + the
|
|
73
73
|
* generic list/edit routes. Empty when the server registers none. */
|
|
74
74
|
collections: CollectionMeta[];
|
|
75
|
+
/** Content types registered on the server (from `listContentTypes`). More than one ⇒ each
|
|
76
|
+
* gets its own nav tab and its own list route, instead of one pooled "Pages" list where a
|
|
77
|
+
* page and an article sit in the same column with nothing to tell them apart. */
|
|
78
|
+
contentTypes: ContentType[];
|
|
75
79
|
/** What the SERVER says this deployment supports (from `listCmsCapabilities`) — today,
|
|
76
80
|
* its declared locales. The editor renders its i18n surface off this rather than a local
|
|
77
81
|
* flag, so the UI and the data can never disagree about whether the site is multilingual. */
|
|
@@ -102,6 +106,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
102
106
|
const [cfg, setCfg] = useState<Config>(() => loadConfig(BACKEND));
|
|
103
107
|
const [me, setMe] = useState<Me | null>(null);
|
|
104
108
|
const [collections, setCollections] = useState<CollectionMeta[]>([]);
|
|
109
|
+
const [contentTypes, setContentTypes] = useState<ContentType[]>([]);
|
|
105
110
|
const [cms, setCms] = useState<CmsCapabilities>(DEFAULT_CAPABILITIES);
|
|
106
111
|
const [error, setError] = useState("");
|
|
107
112
|
// A usable session = somewhere to call + a token that is NOT expired. An expired token
|
|
@@ -144,6 +149,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
144
149
|
// Collections drive the nav + list/edit routes. An app that registers none (or an older
|
|
145
150
|
// server without the handler) just leaves the nav as-is — a failure is non-fatal.
|
|
146
151
|
api.call<CollectionMeta[]>("listCollections").then(setCollections).catch(() => setCollections([]));
|
|
152
|
+
// Drives the per-type nav + list routes. A failure leaves it empty, which falls back to
|
|
153
|
+
// the single pooled "Pages" tab — the layout before types had tabs of their own.
|
|
154
|
+
api.listContentTypes().then(setContentTypes).catch(() => setContentTypes([]));
|
|
147
155
|
// A server older than this handler leaves the monolingual default, which is the safe
|
|
148
156
|
// way round: the i18n surface stays hidden rather than half-rendered.
|
|
149
157
|
api.call<CmsCapabilities>("listCmsCapabilities").then(setCms).catch(() => setCms(DEFAULT_CAPABILITIES));
|
|
@@ -161,6 +169,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
161
169
|
me,
|
|
162
170
|
isAdmin: (me?.roles ?? []).includes("admin"),
|
|
163
171
|
collections,
|
|
172
|
+
contentTypes,
|
|
164
173
|
cms,
|
|
165
174
|
error,
|
|
166
175
|
setError,
|
package/src/buzola.gen.ts
CHANGED
|
@@ -12,8 +12,9 @@ const Route3 = () => import('./routes/home').then(m => ({ default: m.default.com
|
|
|
12
12
|
const Route4 = () => import('./routes/media').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
13
13
|
const Route5 = () => import('./routes/page').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
14
14
|
const Route6 = () => import('./routes/settings').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
15
|
-
const Route7 = () => import('./routes/
|
|
16
|
-
const Route8 = () => import('./routes/
|
|
15
|
+
const Route7 = () => import('./routes/type').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
16
|
+
const Route8 = () => import('./routes/users').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
17
|
+
const Route9 = () => import('./routes/_404').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
|
|
17
18
|
|
|
18
19
|
// Module augmentation for type-safe page-centric routing
|
|
19
20
|
declare module '@buzola/router' {
|
|
@@ -24,6 +25,7 @@ declare module '@buzola/router' {
|
|
|
24
25
|
'media': {};
|
|
25
26
|
'page': { pageId: string; tab?: string };
|
|
26
27
|
'settings': {};
|
|
28
|
+
'type': { slug: string };
|
|
27
29
|
'users': {};
|
|
28
30
|
}
|
|
29
31
|
}
|
|
@@ -36,6 +38,7 @@ export const pageRegistry: Record<string, string> = {
|
|
|
36
38
|
'media': '/media',
|
|
37
39
|
'page': '/pages/:pageId',
|
|
38
40
|
'settings': '/settings',
|
|
41
|
+
'type': '/types/:slug',
|
|
39
42
|
'users': '/users',
|
|
40
43
|
};
|
|
41
44
|
|
|
@@ -80,15 +83,21 @@ const routeConfigs: RouteConfig[] = [
|
|
|
80
83
|
preload: Route6,
|
|
81
84
|
},
|
|
82
85
|
{
|
|
83
|
-
path: '/
|
|
86
|
+
path: '/type',
|
|
87
|
+
matchPath: '/types/:slug',
|
|
84
88
|
component: lazy(Route7),
|
|
85
89
|
preload: Route7,
|
|
86
90
|
},
|
|
87
91
|
{
|
|
88
|
-
path: '
|
|
92
|
+
path: '/users',
|
|
89
93
|
component: lazy(Route8),
|
|
90
94
|
preload: Route8,
|
|
91
95
|
},
|
|
96
|
+
{
|
|
97
|
+
path: '/:__notFound+',
|
|
98
|
+
component: lazy(Route9),
|
|
99
|
+
preload: Route9,
|
|
100
|
+
},
|
|
92
101
|
],
|
|
93
102
|
},
|
|
94
103
|
];
|
package/src/components.tsx
CHANGED
|
@@ -108,13 +108,17 @@ const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-
|
|
|
108
108
|
|
|
109
109
|
// --- pages list --------------------------------------------------------------
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
/** The page list. `type` scopes it to one content type — the list is already filtered by the
|
|
112
|
+
* caller, this is what makes the SCREEN say so: the heading is the type's name and the create
|
|
113
|
+
* modal opens on that type instead of asking again. Omitted ⇒ the pooled list over every type,
|
|
114
|
+
* which is what a single-type deployment should keep seeing. */
|
|
115
|
+
export function PageList({ api, pages, blockTypes, type, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; type?: ContentType; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
112
116
|
// From the SERVER (listCmsCapabilities), not a local flag — see `CmsCapabilities`.
|
|
113
117
|
const { cms: { multilingual } } = useApp();
|
|
114
118
|
const [creating, setCreating] = useState(false);
|
|
115
119
|
return (
|
|
116
120
|
<>
|
|
117
|
-
<Hero lead="Pages" em={pages.length === 0 ? "None yet" : pages.length === 1 ? "1
|
|
121
|
+
<Hero lead={type?.name ?? "Pages"} em={pages.length === 0 ? "None yet" : pages.length === 1 ? "1 entry total" : `${pages.length} entries total`}>
|
|
118
122
|
<Cta text="Let's" em="create something">
|
|
119
123
|
<Button className="shrink-0" onPress={() => setCreating(true)}>+ New page</Button>
|
|
120
124
|
</Cta>
|
|
@@ -129,22 +133,26 @@ export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }:
|
|
|
129
133
|
<Pill status={p.status}>{p.status}</Pill>
|
|
130
134
|
</div>
|
|
131
135
|
))}
|
|
132
|
-
{pages.length === 0 ? <p className="text-fg-subtle">No
|
|
136
|
+
{pages.length === 0 ? <p className="text-fg-subtle">No entries yet. {blockTypes.length === 0 ? "Define block types + a content type first (via the API/admin)." : "Create one."}</p> : null}
|
|
133
137
|
</div>
|
|
134
138
|
</div>
|
|
135
|
-
{creating ? <CreatePage api={api} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); onCreated(); }} onError={onError} /> : null}
|
|
139
|
+
{creating ? <CreatePage api={api} type={type} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); onCreated(); }} onError={onError} /> : null}
|
|
136
140
|
</>
|
|
137
141
|
);
|
|
138
142
|
}
|
|
139
143
|
|
|
140
|
-
function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: () => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
144
|
+
function CreatePage({ api, type, onClose, onCreated, onError }: { api: Api; type?: ContentType; onClose: () => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
141
145
|
const [cts, setCts] = useState<ContentType[]>([]);
|
|
142
|
-
const [typeId, setTypeId] = useState("");
|
|
146
|
+
const [typeId, setTypeId] = useState(type?.id ?? "");
|
|
143
147
|
const [title, setTitle] = useState("");
|
|
144
148
|
const [slug, setSlug] = useState("");
|
|
145
149
|
useEffect(() => {
|
|
150
|
+
// On a type-scoped list the type is already decided by the screen you are on, so don't
|
|
151
|
+
// ask — and don't fetch the other types just to render a picker that must not move the
|
|
152
|
+
// entry out from under that screen.
|
|
153
|
+
if (type) return;
|
|
146
154
|
api.listContentTypes().then((r) => { setCts(r); if (r[0]) setTypeId(r[0].id); }).catch((e) => onError(errMsg(e)));
|
|
147
|
-
}, [api, onError]);
|
|
155
|
+
}, [api, type, onError]);
|
|
148
156
|
const create = async () => {
|
|
149
157
|
try {
|
|
150
158
|
await api.call("createPage", { typeId, title, slug: slug || slugify(title) });
|
|
@@ -157,7 +165,7 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
|
|
|
157
165
|
<Modal onClose={onClose}>
|
|
158
166
|
<ModalTitle>Create a <Dim>new page</Dim> and define the essentials<Dim>.</Dim></ModalTitle>
|
|
159
167
|
<div className="flex flex-col gap-4">
|
|
160
|
-
<div className=
|
|
168
|
+
<div className={`w-full flex-col gap-2 ${type ? "hidden" : "flex"}`}>
|
|
161
169
|
<span className="text-sm font-medium text-fg">Content type</span>
|
|
162
170
|
{/* Visible cards, not a dropdown: the type is an easy-to-miss choice, and picking the
|
|
163
171
|
wrong one puts the entry under a different route. Cards make the selection deliberate. */}
|
package/src/mount.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import type { BuzolaNavigateEvent, NavigationAdapter } from "@buzola/router";
|
|
19
19
|
|
|
20
|
-
/** A mount path must be a chain of path segments rooted at the origin
|
|
20
|
+
/** A mount path must be a chain of non-empty path segments rooted at the origin, written
|
|
21
|
+
* only in characters that `URL.pathname` returns UNENCODED.
|
|
21
22
|
*
|
|
22
23
|
* Rooted, and required to say so: buzola prepends the base path to every href it builds, so
|
|
23
24
|
* anything that is not already an absolute path resolves somewhere unintended. A leading
|
|
@@ -26,10 +27,14 @@ import type { BuzolaNavigateEvent, NavigationAdapter } from "@buzola/router";
|
|
|
26
27
|
* mounted. `//cdn.example.com` is rejected for the same reason: protocol-relative, and the
|
|
27
28
|
* natural product of `"/" + prefix` where the prefix already carried a slash.
|
|
28
29
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
30
|
+
* The character set is not a style choice. Every comparison against a mount path in this
|
|
31
|
+
* file — and buzola's own `stripBasePath`, which we cannot change — is a raw `startsWith`
|
|
32
|
+
* against a `URL.pathname`, which is percent-ENCODED. Admit a character the parser encodes
|
|
33
|
+
* and the two sides can never match: a mount of `/správa` is compared against
|
|
34
|
+
* `/spr%C3%A1va/...` and EVERY in-prefix url reads as off-prefix. So the admitted set is
|
|
35
|
+
* derived from the parser rather than guessed — `"<>^`{}`, backslash, space and everything
|
|
36
|
+
* non-ASCII all encode, and are refused here. */
|
|
37
|
+
const MOUNT_PATH = /^(?:\/[A-Za-z0-9!$%&'()*+,\-.:;=@[\]_|~]+)+$/;
|
|
33
38
|
|
|
34
39
|
/**
|
|
35
40
|
* Normalize the mount node's declared prefix to buzola's shape: `""` for the origin root,
|
|
@@ -43,12 +48,16 @@ const MOUNT_PATH = /^\/[^/\\?#][^\\?#]*$/;
|
|
|
43
48
|
*/
|
|
44
49
|
export function resolveBasePath(raw?: string | null): string {
|
|
45
50
|
const trimmed = (raw ?? "").trim();
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
// Trailing slashes come off FIRST, so `MOUNT_PATH` only ever judges the canonical form
|
|
52
|
+
// and a value that is nothing but slashes collapses to the root rather than being warned
|
|
53
|
+
// about as malformed.
|
|
54
|
+
const canonical = trimmed.replace(/\/+$/, "");
|
|
55
|
+
if (canonical === "") return "";
|
|
56
|
+
if (!MOUNT_PATH.test(canonical)) {
|
|
48
57
|
console.warn(`pramen/cms-editor: ignoring unusable mount path ${JSON.stringify(trimmed)} — mounting at the origin root.`);
|
|
49
58
|
return "";
|
|
50
59
|
}
|
|
51
|
-
return
|
|
60
|
+
return canonical;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
/** Read the prefix the shell stamped onto the mount node. */
|
|
@@ -86,13 +95,19 @@ export function scopeToBasePath(inner: NavigationAdapter, basePath: string): Nav
|
|
|
86
95
|
// Keyed by the caller's handler so `removeEventListener` can find the wrapper it added;
|
|
87
96
|
// without this the router's `start()` teardown would leave the listener attached.
|
|
88
97
|
const wrappers = new Map<Handler, Handler>();
|
|
98
|
+
// Spread, not a hand-written list of the seven methods. Enumerating them forwards
|
|
99
|
+
// correctly today but only fails safe by luck: tsc catches a newly REQUIRED member of
|
|
100
|
+
// `NavigationAdapter`, while an OPTIONAL one is silently dropped — and the next planned
|
|
101
|
+
// buzola bump (past ^0.0.12, for `router.leaveApp`) is exactly where such a member would
|
|
102
|
+
// arrive. Overriding the two listener methods is the whole of what this wrapper does.
|
|
89
103
|
return {
|
|
90
|
-
|
|
91
|
-
navigate: (url, options) => inner.navigate(url, options),
|
|
92
|
-
back: () => inner.back(),
|
|
93
|
-
forward: () => inner.forward(),
|
|
94
|
-
getState: () => inner.getState(),
|
|
104
|
+
...inner,
|
|
95
105
|
addEventListener(type, handler) {
|
|
106
|
+
// Adding the same handler twice would attach two wrappers to the inner adapter while
|
|
107
|
+
// the map kept only the second, so the first could never be removed and would keep
|
|
108
|
+
// feeding a torn-down router. Nothing does this today (`Router.start()` mints a fresh
|
|
109
|
+
// closure per call), which is precisely why it should be closed while it is free.
|
|
110
|
+
if (wrappers.has(handler)) return;
|
|
96
111
|
const wrapper: Handler = (event) => {
|
|
97
112
|
// A malformed destination is not ours to claim.
|
|
98
113
|
let pathname: string;
|
|
@@ -115,6 +130,51 @@ export function scopeToBasePath(inner: NavigationAdapter, basePath: string): Nav
|
|
|
115
130
|
};
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Whether a host-configured nav link may navigate the CURRENT tab.
|
|
135
|
+
*
|
|
136
|
+
* The default for `extraNav` is a new tab, because `_404.tsx` registers the catch-all
|
|
137
|
+
* `/:__notFound+`: an unmounted editor's router matches every same-origin path, so a
|
|
138
|
+
* same-tab click would land on the in-app 404 instead of the tool. `target: "_self"` asks
|
|
139
|
+
* for the co-hosted case, and is honoured only where it cannot strand the user.
|
|
140
|
+
*
|
|
141
|
+
* Lives here, not in the layout, for two reasons: these are the same containment rules
|
|
142
|
+
* `scopeToBasePath` enforces and they belong beside them, and a predicate that decides
|
|
143
|
+
* whether a click escapes the SPA has to be testable without a DOM. `documentUrl` is a
|
|
144
|
+
* PARAMETER rather than a read of `window.location` for the same reason — and because it
|
|
145
|
+
* is the one input that must not be guessed:
|
|
146
|
+
*
|
|
147
|
+
* - Resolve against `location.origin` and a relative href like `"curate"` is judged as
|
|
148
|
+
* `/curate` while the browser navigates to `<current dir>/curate`. Off-prefix by the
|
|
149
|
+
* check, in-prefix in fact, so the link lands on the very 404 this exists to avoid.
|
|
150
|
+
* `?tab=x` and `#top` are the same mistake with an even wider gap.
|
|
151
|
+
* - So the caller passes the document URL the browser will itself resolve against.
|
|
152
|
+
*/
|
|
153
|
+
export function opensInSameTab(href: string, target: string | undefined, basePath: string, documentUrl: string): boolean {
|
|
154
|
+
if (target !== "_self") return false;
|
|
155
|
+
let url: URL;
|
|
156
|
+
let docOrigin: string;
|
|
157
|
+
try {
|
|
158
|
+
url = new URL(href, documentUrl);
|
|
159
|
+
docOrigin = new URL(documentUrl).origin;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
// `new URL` happily parses `javascript:` and `data:`, and their `pathname` is an opaque
|
|
164
|
+
// string that trivially fails any containment test — so without this they would take the
|
|
165
|
+
// same-tab branch and shed the `rel` that used to confine them. A url with no hierarchical
|
|
166
|
+
// path cannot be reasoned about as "inside or outside the mount"; the answer is no.
|
|
167
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
168
|
+
// Cross-origin ALWAYS escapes on its own — the catch-all can only claim same-origin paths
|
|
169
|
+
// — so it is safe in the same tab whether or not this editor is mounted. That makes an
|
|
170
|
+
// external tool the one configuration that works at the origin root, which is the opposite
|
|
171
|
+
// of what a bare `basePath` check concludes.
|
|
172
|
+
if (url.origin !== docOrigin) return true;
|
|
173
|
+
// Same origin: safe only where the router will not claim it. At the root `isWithinBasePath`
|
|
174
|
+
// is true for everything, so this correctly refuses every same-origin `_self` there.
|
|
175
|
+
return !isWithinBasePath(url.pathname, basePath);
|
|
176
|
+
}
|
|
177
|
+
|
|
118
178
|
/** The backend the shell declared: which Worker to call, and as which tenant.
|
|
119
179
|
*
|
|
120
180
|
* When present these are NOT read from (or written to) localStorage — the server knows
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -2,24 +2,28 @@
|
|
|
2
2
|
// wrapped around every route via <Outlet />. Tab highlighting is derived from the
|
|
3
3
|
// current path, so a deep link or refresh lands with the right tab lit.
|
|
4
4
|
|
|
5
|
-
import { Outlet, useNavigate, useRoute } from "@buzola/router";
|
|
5
|
+
import { Outlet, useNavigate, useRoute, useRouter } from "@buzola/router";
|
|
6
6
|
import { Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { useApp } from "../app-context";
|
|
9
9
|
import { BRAND } from "../brand";
|
|
10
|
+
import { opensInSameTab } from "../mount";
|
|
10
11
|
|
|
11
12
|
const THEME_KEY = "pramen.cms.theme";
|
|
12
13
|
|
|
13
14
|
export default function RootLayout() {
|
|
14
|
-
const { isAdmin, collections, error, reconfigure, confirmNavigation } = useApp();
|
|
15
|
+
const { isAdmin, collections, contentTypes, error, reconfigure, confirmNavigation } = useApp();
|
|
15
16
|
const navigate = useNavigate();
|
|
16
17
|
const { pathname } = useRoute();
|
|
17
18
|
|
|
18
19
|
// Every chrome action here is a way OUT of the current screen, so it runs through that
|
|
19
20
|
// screen's unsaved-changes guard first (the page editor registers one; with no guard
|
|
20
21
|
// registered this is a pass-through). In-app navigation fires no `beforeunload`, so
|
|
21
|
-
// without this the topbar silently discards unsaved edits.
|
|
22
|
-
//
|
|
22
|
+
// without this the topbar silently discards unsaved edits. An `extraNav` link that opens a
|
|
23
|
+
// NEW tab leaves this document alone and needs no guard; one honoured as `_self` is a real
|
|
24
|
+
// cross-document navigation, so it takes the guard too (below). `beforeunload` is not a
|
|
25
|
+
// fallback for it — only `PageEditor` registers one, so a dirty CollectionEditor form would
|
|
26
|
+
// otherwise be discarded with no prompt of any kind.
|
|
23
27
|
const guarded = (go: () => void) => () => { if (confirmNavigation()) go(); };
|
|
24
28
|
|
|
25
29
|
// Dark mode: podoba tokens flip under `[data-theme="dark"]` — no `dark:` prefixes.
|
|
@@ -31,9 +35,15 @@ export default function RootLayout() {
|
|
|
31
35
|
|
|
32
36
|
// The active collection slug, if we're under /collections/:slug(/...).
|
|
33
37
|
const collectionSlug = pathname.startsWith("/collections/") ? pathname.split("/")[2] : undefined;
|
|
38
|
+
// …and the active content type, under /types/:slug.
|
|
39
|
+
const typeSlug = pathname.startsWith("/types/") ? pathname.split("/")[2] : undefined;
|
|
34
40
|
|
|
35
|
-
// "Pages" stays lit while editing a page (/pages/:id) too
|
|
41
|
+
// "Pages" stays lit while editing a page (/pages/:id) too — but only on a deployment that
|
|
42
|
+
// still HAS a pooled Pages tab. Split by type, the page editor lights nothing: the route
|
|
43
|
+
// carries a page id and nothing else, so which type's tab to light isn't knowable here
|
|
44
|
+
// without fetching the page the editor is already fetching.
|
|
36
45
|
const active = collectionSlug ? `col:${collectionSlug}`
|
|
46
|
+
: typeSlug ? `type:${typeSlug}`
|
|
37
47
|
: pathname.startsWith("/pages") || pathname === "/" ? "pages"
|
|
38
48
|
: pathname.startsWith("/media") ? "media"
|
|
39
49
|
: pathname.startsWith("/users") ? "users"
|
|
@@ -47,6 +57,13 @@ export default function RootLayout() {
|
|
|
47
57
|
// Collections-only deployments hide the block/page builder entirely.
|
|
48
58
|
const hidePages = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.hidePages === true : false;
|
|
49
59
|
|
|
60
|
+
// See the extraNav comment below. The rules live in `mount.ts` beside the containment they
|
|
61
|
+
// depend on; what this supplies is the URL the BROWSER will resolve a relative href
|
|
62
|
+
// against — the current document, not the origin. Empty when there is no `window`, which
|
|
63
|
+
// makes every href unparseable and so degrades to the safe new-tab default.
|
|
64
|
+
const basePath = useRouter().basePath;
|
|
65
|
+
const documentUrl = typeof window !== "undefined" ? window.location.href : "";
|
|
66
|
+
|
|
50
67
|
return (
|
|
51
68
|
// Page-level surface so the whole viewport (not just the topbar + cards) flips
|
|
52
69
|
// under `[data-theme="dark"]` — otherwise the body stays white in dark mode.
|
|
@@ -66,7 +83,25 @@ export default function RootLayout() {
|
|
|
66
83
|
</button>
|
|
67
84
|
</Topbar.Brand>
|
|
68
85
|
<Topbar.Nav aria-label="Primary">
|
|
69
|
-
{
|
|
86
|
+
{/* One tab per content type once a deployment declares more than one: a CMS holding
|
|
87
|
+
pages AND articles pooled them into a single "Pages" list where the only thing
|
|
88
|
+
distinguishing a landing page from a news item was the slug. A single type keeps
|
|
89
|
+
the plain "Pages" tab — there is nothing there to separate, and splitting it
|
|
90
|
+
would put the deployment's own type name where a generic label reads better.
|
|
91
|
+
The label is the type's `name`, so a host that wants a plural tab writes one. */}
|
|
92
|
+
{hidePages ? null : contentTypes.length > 1 ? (
|
|
93
|
+
contentTypes.map((t) => (
|
|
94
|
+
<Button
|
|
95
|
+
key={t.slug}
|
|
96
|
+
variant="ghost"
|
|
97
|
+
size="sm"
|
|
98
|
+
className={tabCls(`type:${t.slug}`)}
|
|
99
|
+
onPress={guarded(() => navigate("type", { params: { slug: t.slug } }))}
|
|
100
|
+
>
|
|
101
|
+
{t.name}
|
|
102
|
+
</Button>
|
|
103
|
+
))
|
|
104
|
+
) : (
|
|
70
105
|
<Button variant="ghost" size="sm" className={tabCls("pages")} onPress={guarded(() => navigate("home"))}>
|
|
71
106
|
Pages
|
|
72
107
|
</Button>
|
|
@@ -89,24 +124,21 @@ export default function RootLayout() {
|
|
|
89
124
|
Settings
|
|
90
125
|
</Button>
|
|
91
126
|
{extraNav.map((l) => (
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
127
|
+
// Defaults to a new tab: a companion tool is normally a separate deployment, and
|
|
128
|
+
// `_404.tsx` registers the catch-all `/:__notFound+`, so the router matches every
|
|
129
|
+
// SAME-ORIGIN path — a same-tab click would land on the in-app 404 rather than the
|
|
130
|
+
// tool. `target: "_self"` asks for the co-hosted case, and is honoured only where
|
|
131
|
+
// the router provably will not claim the url (`opensInSameTab` in mount.ts):
|
|
132
|
+
//
|
|
133
|
+
// - cross-origin: always, mounted or not. The catch-all cannot reach another
|
|
134
|
+
// origin, so this is the ONE case that also works at the origin root.
|
|
135
|
+
// - same-origin: only outside the mount prefix, where `scopeToBasePath` leaves
|
|
136
|
+
// the navigation to the browser. At the root there is no outside, so never.
|
|
97
137
|
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
<
|
|
102
|
-
key={l.href}
|
|
103
|
-
href={l.href}
|
|
104
|
-
target="_blank"
|
|
105
|
-
rel="noopener noreferrer"
|
|
106
|
-
className="rounded-md px-2.5 py-1.5 text-small text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg"
|
|
107
|
-
>
|
|
108
|
-
{l.label}
|
|
109
|
-
</a>
|
|
138
|
+
// Anything else degrades to the new tab rather than stranding the user on a 404.
|
|
139
|
+
// (When @buzola/router moves past ^0.0.12 here, `router.leaveApp(href)` releases
|
|
140
|
+
// one navigation to the browser and makes same-origin `_self` work unmounted too.)
|
|
141
|
+
<NavLink key={`${l.href}|${l.label}`} link={l} sameTab={opensInSameTab(l.href, l.target, basePath, documentUrl)} confirm={confirmNavigation} />
|
|
110
142
|
))}
|
|
111
143
|
</Topbar.Nav>
|
|
112
144
|
<Topbar.Actions>
|
|
@@ -136,3 +168,29 @@ export default function RootLayout() {
|
|
|
136
168
|
</div>
|
|
137
169
|
);
|
|
138
170
|
}
|
|
171
|
+
|
|
172
|
+
/** One host-configured link to a companion tool.
|
|
173
|
+
*
|
|
174
|
+
* `rel="noreferrer"` is on BOTH branches. `noopener` is genuinely moot in the same tab (no
|
|
175
|
+
* new browsing context is created, so there is no `window.opener` to sever) but `noreferrer`
|
|
176
|
+
* is not: without it a click from `/_pramen/admin/pages/<id>` hands that full url to the
|
|
177
|
+
* destination as `Referer`, and `_self` is honoured for cross-origin destinations.
|
|
178
|
+
*
|
|
179
|
+
* The key pairs href with label, because two entries may legitimately point at the same href
|
|
180
|
+
* and differ only in label or target — keyed on href alone React reconciles them together
|
|
181
|
+
* and the rendered label can end up on the other one's anchor.
|
|
182
|
+
*/
|
|
183
|
+
function NavLink({ link, sameTab, confirm }: { link: { label: string; href: string; target?: string }; sameTab: boolean; confirm: () => boolean }) {
|
|
184
|
+
return (
|
|
185
|
+
<a
|
|
186
|
+
href={link.href}
|
|
187
|
+
rel={sameTab ? "noreferrer" : "noopener noreferrer"}
|
|
188
|
+
// Only the same-tab case unloads this document, so only it consults the guard.
|
|
189
|
+
onClick={sameTab ? (e) => { if (!confirm()) e.preventDefault(); } : undefined}
|
|
190
|
+
{...(sameTab ? {} : { target: "_blank" })}
|
|
191
|
+
className="rounded-md px-2.5 py-1.5 text-small text-fg-muted transition-colors hover:bg-surface-muted hover:text-fg"
|
|
192
|
+
>
|
|
193
|
+
{link.label}
|
|
194
|
+
</a>
|
|
195
|
+
);
|
|
196
|
+
}
|
package/src/routes/home.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import type { BlockType, Page } from "../types";
|
|
|
9
9
|
export default createPage()
|
|
10
10
|
.route("/")
|
|
11
11
|
.render(function Home() {
|
|
12
|
-
const { api, setError, collections } = useApp();
|
|
12
|
+
const { api, setError, collections, contentTypes } = useApp();
|
|
13
13
|
const navigate = useNavigate();
|
|
14
14
|
const [pages, setPages] = useState<Page[]>([]);
|
|
15
15
|
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
@@ -20,20 +20,28 @@ export default createPage()
|
|
|
20
20
|
// deployment that never spread cmsSchema.
|
|
21
21
|
const hidePages = typeof window !== "undefined" && window.PRAMEN_CMS_EDITOR?.hidePages === true;
|
|
22
22
|
const firstCollection = collections[0]?.slug;
|
|
23
|
+
// With more than one content type each gets its own tab and its own list (`/types/:slug`),
|
|
24
|
+
// so the pooled list here has no tab of its own to be reached from and would just be a
|
|
25
|
+
// fourth way to see the same rows. Land on the first type instead. One type (or none, on a
|
|
26
|
+
// server too old to answer) keeps the pooled list — that IS the whole CMS there.
|
|
27
|
+
const splitByType = !hidePages && contentTypes.length > 1;
|
|
28
|
+
const firstType = contentTypes[0]?.slug;
|
|
23
29
|
|
|
24
30
|
useEffect(() => {
|
|
25
31
|
if (hidePages && firstCollection) navigate("collection", { params: { slug: firstCollection }, replace: true });
|
|
26
|
-
|
|
32
|
+
else if (splitByType && firstType) navigate("type", { params: { slug: firstType }, replace: true });
|
|
33
|
+
}, [hidePages, firstCollection, splitByType, firstType, navigate]);
|
|
27
34
|
|
|
28
35
|
const refreshPages = () => api.listPages().then(setPages).catch((e) => setError(errMsg(e)));
|
|
29
36
|
useEffect(() => {
|
|
30
|
-
if (hidePages) return;
|
|
37
|
+
if (hidePages || splitByType) return;
|
|
31
38
|
refreshPages();
|
|
32
39
|
api.listBlockTypes().then(setBlockTypes).catch((e) => setError(errMsg(e)));
|
|
33
40
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
|
-
}, [api, hidePages]);
|
|
41
|
+
}, [api, hidePages, splitByType]);
|
|
35
42
|
|
|
36
43
|
if (hidePages && firstCollection) return null;
|
|
44
|
+
if (splitByType) return null;
|
|
37
45
|
|
|
38
46
|
return (
|
|
39
47
|
<PageList
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// One content type's page list (`/types/:slug`). The pooled list at `/` is what a
|
|
2
|
+
// single-type deployment wants; the moment a deployment declares two (pages AND articles,
|
|
3
|
+
// say) that list stops being a list of anything in particular — same column, same rows,
|
|
4
|
+
// nothing to tell a landing page from a news item. This route is that list scoped to one
|
|
5
|
+
// type, and `_layout` gives each type a tab pointing at it.
|
|
6
|
+
|
|
7
|
+
import { createPage, useNavigate } from "@buzola/router";
|
|
8
|
+
import { Button } from "@podoba/react";
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { useApp } from "../app-context";
|
|
11
|
+
import { PageList, errMsg } from "../components";
|
|
12
|
+
import type { BlockType, Page } from "../types";
|
|
13
|
+
|
|
14
|
+
export default createPage()
|
|
15
|
+
.params({ slug: "string" })
|
|
16
|
+
.route("/types/:slug")
|
|
17
|
+
.render(function ContentTypeRoute({ params }) {
|
|
18
|
+
const { api, contentTypes, setError } = useApp();
|
|
19
|
+
const navigate = useNavigate();
|
|
20
|
+
const [pages, setPages] = useState<Page[]>([]);
|
|
21
|
+
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
22
|
+
const type = contentTypes.find((t) => t.slug === params.slug);
|
|
23
|
+
|
|
24
|
+
// Filtered SERVER-side (`listPages` takes the slug), so this stays correct past the
|
|
25
|
+
// handler's 100-row cap — narrowing a pooled fetch here would quietly drop the tail.
|
|
26
|
+
// Keyed on the slug, not on `type`: the fetch must not wait on listContentTypes.
|
|
27
|
+
const refreshPages = () => api.listPages(params.slug).then(setPages).catch((e) => setError(errMsg(e)));
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
refreshPages();
|
|
30
|
+
api.listBlockTypes().then(setBlockTypes).catch((e) => setError(errMsg(e)));
|
|
31
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
32
|
+
}, [api, params.slug]);
|
|
33
|
+
|
|
34
|
+
// Content types load async; before they arrive (or for a bad slug) show a neutral state
|
|
35
|
+
// rather than an unlabelled list — mirrors the collection route.
|
|
36
|
+
if (!type) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="mx-auto flex max-w-[1200px] items-center gap-2 px-7 pt-8">
|
|
39
|
+
<p className="text-fg-subtle">{contentTypes.length === 0 ? "Loading…" : `Unknown content type: ${params.slug}`}</p>
|
|
40
|
+
{contentTypes.length > 0 ? <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Back</Button> : null}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<PageList
|
|
47
|
+
api={api}
|
|
48
|
+
pages={pages}
|
|
49
|
+
blockTypes={blockTypes}
|
|
50
|
+
type={type}
|
|
51
|
+
onOpen={(p) => navigate("page", { params: { pageId: p.id } })}
|
|
52
|
+
onCreated={refreshPages}
|
|
53
|
+
onError={setError}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
});
|