@pramen/cms-editor 0.0.54 → 0.0.56

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.54",
3
+ "version": "0.0.56",
4
4
  "description": "Visual block/page editor for @pramen/cms \u2014 a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- listPages = () => this.call<Page[]>("listPages");
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
 
@@ -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 {
@@ -64,6 +64,27 @@ export interface Me {
64
64
  [k: string]: JsonValue | undefined;
65
65
  }
66
66
 
67
+ /**
68
+ * Whether the SERVER accepted this session — the answer to `me`, which is the identity it
69
+ * resolved the bearer token to, or `null` when it resolved none.
70
+ *
71
+ * The editor used to decide it was signed in from the token's own `exp` claim alone, read
72
+ * client-side. That covers exactly one way to stop being signed in. Every other way — the
73
+ * signing secret rotated, the account deleted or deactivated, the token revoked onto the
74
+ * denylist, a stored token from an older deployment — leaves an unexpired `exp` on a token
75
+ * the server treats as ANONYMOUS. And anonymous is not an error here: pramen's ACL answers
76
+ * it, so the editor mounted and rendered a shell that looked signed in and was empty.
77
+ * `listContentTypes` and `listBlockTypes` 403 into swallowed catches, `me` comes back null,
78
+ * so the tabs collapse to the pre-types default and the page list shows only what the public
79
+ * can read — no error, no way back, nothing to click.
80
+ *
81
+ * `userId`, not truthiness of the object: `me` for an anonymous caller is `null` today, but
82
+ * an identity carrying no subject is the same non-answer and must not read as a session.
83
+ */
84
+ export function hasIdentity(me: Me | null | undefined): boolean {
85
+ return typeof me?.userId === "string" && me.userId !== "";
86
+ }
87
+
67
88
  interface AppContextValue {
68
89
  api: Api;
69
90
  cfg: Config;
@@ -72,6 +93,10 @@ interface AppContextValue {
72
93
  /** Collections registered on the server (from `listCollections`) — drives the nav + the
73
94
  * generic list/edit routes. Empty when the server registers none. */
74
95
  collections: CollectionMeta[];
96
+ /** Content types registered on the server (from `listContentTypes`). More than one ⇒ each
97
+ * gets its own nav tab and its own list route, instead of one pooled "Pages" list where a
98
+ * page and an article sit in the same column with nothing to tell them apart. */
99
+ contentTypes: ContentType[];
75
100
  /** What the SERVER says this deployment supports (from `listCmsCapabilities`) — today,
76
101
  * its declared locales. The editor renders its i18n surface off this rather than a local
77
102
  * flag, so the UI and the data can never disagree about whether the site is multilingual. */
@@ -102,6 +127,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
102
127
  const [cfg, setCfg] = useState<Config>(() => loadConfig(BACKEND));
103
128
  const [me, setMe] = useState<Me | null>(null);
104
129
  const [collections, setCollections] = useState<CollectionMeta[]>([]);
130
+ const [contentTypes, setContentTypes] = useState<ContentType[]>([]);
105
131
  const [cms, setCms] = useState<CmsCapabilities>(DEFAULT_CAPABILITIES);
106
132
  const [error, setError] = useState("");
107
133
  // A usable session = somewhere to call + a token that is NOT expired. An expired token
@@ -140,10 +166,28 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
140
166
  useEffect(() => {
141
167
  if (!authValid) return;
142
168
  // `me` gates the Users tab + drives Settings — a failing call is fine (leaves it {}).
143
- api.call<Me>("me").then(setMe).catch(() => setMe({}));
169
+ //
170
+ // It is also the only thing that asks the server whether this session is real, so a token
171
+ // it will not accept ends the session HERE rather than rendering an empty editor around it
172
+ // (see `hasIdentity`). A THROWN call is not that answer — a network blip or a deployment
173
+ // without auth handlers must not sign anyone out — only a successful call that resolves to
174
+ // no identity is.
175
+ api
176
+ .call<Me>("me")
177
+ .then((identity) => {
178
+ if (hasIdentity(identity)) { setMe(identity); return; }
179
+ // Same handoff the expiry poll makes: to the sign-in page when the host configured
180
+ // one, otherwise drop the token so the built-in Setup screen comes back.
181
+ if (SIGN_IN_URL) redirectToSignIn();
182
+ else { clearConfig(); setCfg((c) => ({ ...c, token: "" })); }
183
+ })
184
+ .catch(() => setMe({}));
144
185
  // Collections drive the nav + list/edit routes. An app that registers none (or an older
145
186
  // server without the handler) just leaves the nav as-is — a failure is non-fatal.
146
187
  api.call<CollectionMeta[]>("listCollections").then(setCollections).catch(() => setCollections([]));
188
+ // Drives the per-type nav + list routes. A failure leaves it empty, which falls back to
189
+ // the single pooled "Pages" tab — the layout before types had tabs of their own.
190
+ api.listContentTypes().then(setContentTypes).catch(() => setContentTypes([]));
147
191
  // A server older than this handler leaves the monolingual default, which is the safe
148
192
  // way round: the i18n surface stays hidden rather than half-rendered.
149
193
  api.call<CmsCapabilities>("listCmsCapabilities").then(setCms).catch(() => setCms(DEFAULT_CAPABILITIES));
@@ -161,6 +205,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
161
205
  me,
162
206
  isAdmin: (me?.roles ?? []).includes("admin"),
163
207
  collections,
208
+ contentTypes,
164
209
  cms,
165
210
  error,
166
211
  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/users').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
16
- const Route8 = () => import('./routes/_404').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
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: '/users',
86
+ path: '/type',
87
+ matchPath: '/types/:slug',
84
88
  component: lazy(Route7),
85
89
  preload: Route7,
86
90
  },
87
91
  {
88
- path: '/:__notFound+',
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
  ];
@@ -108,13 +108,17 @@ const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-
108
108
 
109
109
  // --- pages list --------------------------------------------------------------
110
110
 
111
- export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
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 page total" : `${pages.length} pages total`}>
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 pages yet. {blockTypes.length === 0 ? "Define block types + a content type first (via the API/admin)." : "Create one."}</p> : null}
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="flex w-full flex-col gap-2">
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. */}
@@ -12,7 +12,7 @@ import { opensInSameTab } from "../mount";
12
12
  const THEME_KEY = "pramen.cms.theme";
13
13
 
14
14
  export default function RootLayout() {
15
- const { isAdmin, collections, error, reconfigure, confirmNavigation } = useApp();
15
+ const { isAdmin, collections, contentTypes, error, reconfigure, confirmNavigation } = useApp();
16
16
  const navigate = useNavigate();
17
17
  const { pathname } = useRoute();
18
18
 
@@ -35,9 +35,15 @@ export default function RootLayout() {
35
35
 
36
36
  // The active collection slug, if we're under /collections/:slug(/...).
37
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;
38
40
 
39
- // "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.
40
45
  const active = collectionSlug ? `col:${collectionSlug}`
46
+ : typeSlug ? `type:${typeSlug}`
41
47
  : pathname.startsWith("/pages") || pathname === "/" ? "pages"
42
48
  : pathname.startsWith("/media") ? "media"
43
49
  : pathname.startsWith("/users") ? "users"
@@ -77,7 +83,25 @@ export default function RootLayout() {
77
83
  </button>
78
84
  </Topbar.Brand>
79
85
  <Topbar.Nav aria-label="Primary">
80
- {hidePages ? null : (
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
+ ) : (
81
105
  <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={guarded(() => navigate("home"))}>
82
106
  Pages
83
107
  </Button>
@@ -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
- }, [hidePages, firstCollection, navigate]);
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
+ });