@pramen/cms-editor 0.0.56 → 0.0.57

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.
@@ -6,14 +6,14 @@ import { createPage, useNavigate } from "@buzola/router";
6
6
  import { Button } from "@podoba/react";
7
7
  import { useEffect, useState } from "react";
8
8
  import { useApp } from "../app-context";
9
- import { PageEditor, errMsg, visibleTabs, type InspectorTab } from "../components";
9
+ import { Notice, PageEditor, errMsg, splitsByType, visibleTabs, type InspectorTab } from "../components";
10
10
  import type { BlockType, Page } from "../types";
11
11
 
12
12
  export default createPage()
13
13
  .params({ pageId: "string", tab: "?string" })
14
14
  .route("/pages/:pageId")
15
15
  .render(function PageEditorRoute({ params }) {
16
- const { api, cms, setError, setNavGuard } = useApp();
16
+ const { api, cms, contentTypes, setError, setNavGuard } = useApp();
17
17
  const navigate = useNavigate();
18
18
  const [page, setPage] = useState<Page | null>(null);
19
19
  const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
@@ -22,12 +22,14 @@ export default createPage()
22
22
  useEffect(() => {
23
23
  let live = true;
24
24
  setMissing(false);
25
- // No get-page-by-id handler; resolve the id against the page list.
26
- api.listPages()
27
- .then((rows) => {
25
+ // By id. Resolving it against `listPages` instead meant the editor could not open a row
26
+ // the list had just shown it: that list is capped, and since the editor lists ONE type
27
+ // per tab it isn't even the same list — 150 newer pages of another type were enough to
28
+ // push an article out of the pooled fetch and turn a click into "Page not found."
29
+ api.getPageById(params.pageId)
30
+ .then((found) => {
28
31
  if (!live) return;
29
- const found = rows.find((p) => p.id === params.pageId) ?? null;
30
- setPage(found);
32
+ setPage(found ?? null);
31
33
  setMissing(!found);
32
34
  })
33
35
  .catch((e) => setError(errMsg(e)));
@@ -35,6 +37,16 @@ export default createPage()
35
37
  return () => { live = false; };
36
38
  }, [api, params.pageId, setError]);
37
39
 
40
+ // Back goes to the list this page actually belongs to. `home` redirects to whichever type
41
+ // sorts FIRST BY NAME on a split deployment, so "← all pages" from an article landed the
42
+ // editor in some other type's list — with the `replace` overwriting `/`, so Back could not
43
+ // undo it either. Falls back to `home` when the type is unknown (not loaded, or gone).
44
+ const ownType = (contentTypes ?? []).find((t) => t.id === page?.typeId);
45
+ const backToList = () => {
46
+ if (splitsByType(contentTypes, cms) && ownType) navigate("type", { params: { slug: ownType.slug } });
47
+ else navigate("home");
48
+ };
49
+
38
50
  // The visible set comes from the server (`visibleTabs`), so a deep link to `?tab=i18n`
39
51
  // on a single-locale deployment falls back to settings — and the URL is REWRITTEN to
40
52
  // match. Rendering one tab under another tab's address means a refresh, a Back, or a
@@ -48,14 +60,10 @@ export default createPage()
48
60
  }, [params.tab, tab]);
49
61
 
50
62
  if (missing) {
51
- return (
52
- <div className="mx-auto flex max-w-[1200px] items-center gap-2 px-7 pt-8">
53
- <p className="text-fg-subtle">Page not found.</p>
54
- <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← all pages</Button>
55
- </div>
56
- );
63
+ // No page ⇒ no type to go back to; `home` lands on the first list either way.
64
+ return <Notice action={<Button variant="ghost" size="sm" onPress={() => navigate("home")}>← all pages</Button>}>Page not found.</Notice>;
57
65
  }
58
- if (!page) return <div className="mx-auto max-w-[1200px] px-7 pt-8"><p className="text-fg-subtle">Loading…</p></div>;
66
+ if (!page) return <Notice>Loading…</Notice>;
59
67
 
60
68
  return (
61
69
  <PageEditor
@@ -64,7 +72,7 @@ export default createPage()
64
72
  blockTypes={blockTypes}
65
73
  tab={tab}
66
74
  onTab={setTab}
67
- onBack={() => navigate("home")}
75
+ onBack={backToList}
68
76
  onChange={setPage}
69
77
  registerGuard={setNavGuard}
70
78
  />
@@ -6,50 +6,43 @@
6
6
 
7
7
  import { createPage, useNavigate } from "@buzola/router";
8
8
  import { Button } from "@podoba/react";
9
- import { useEffect, useState } from "react";
10
9
  import { useApp } from "../app-context";
11
- import { PageList, errMsg } from "../components";
12
- import type { BlockType, Page } from "../types";
10
+ import { Notice, PageList, pagesHidden } from "../components";
13
11
 
14
12
  export default createPage()
15
13
  .params({ slug: "string" })
16
14
  .route("/types/:slug")
17
15
  .render(function ContentTypeRoute({ params }) {
18
- const { api, contentTypes, setError } = useApp();
16
+ const { contentTypes, contentTypesFailed, refreshContentTypes, api, setError } = useApp();
19
17
  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);
18
+ const type = (contentTypes ?? []).find((t) => t.slug === params.slug);
19
+ const back = <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Back</Button>;
23
20
 
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]);
21
+ // This route IS the block/page builder, so a deployment that hides it hides this too —
22
+ // otherwise the flag is bypassed by typing the URL, or by a stale link.
23
+ if (pagesHidden()) return <Notice action={back}>The page builder is not enabled on this deployment.</Notice>;
33
24
 
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
- );
25
+ // `null` = listContentTypes has not answered yet; `failed` = it answered with an error and
26
+ // this screen cannot name its own type. The two used to collapse into an empty array, so a
27
+ // 5xx (or a session outside `editorRoles ∪ reviewerRoles`, which cannot call
28
+ // listContentTypes at all) sat on "Loading…" forever with the way back suppressed.
29
+ if (contentTypesFailed) {
30
+ return <Notice action={<Button variant="ghost" size="sm" onPress={refreshContentTypes}>Retry</Button>}>Couldn&apos;t load the content types.</Notice>;
43
31
  }
32
+ if (contentTypes === null) return <Notice>Loading…</Notice>;
33
+ // Nothing is fetched for a slug that is not a content type — the list would be empty and
34
+ // the heading unlabelled, and both round trips are wasted.
35
+ if (!type) return <Notice action={back}>Unknown content type: {params.slug}</Notice>;
44
36
 
45
37
  return (
46
38
  <PageList
47
39
  api={api}
48
- pages={pages}
49
- blockTypes={blockTypes}
40
+ // Keyed on the slug so switching tabs REMOUNTS the list: buzola renders the same
41
+ // component instance across a params-only change, and PageList holds the rows,
42
+ // the paging offset and any open create-modal — all of which belong to one type.
43
+ key={type.slug}
50
44
  type={type}
51
45
  onOpen={(p) => navigate("page", { params: { pageId: p.id } })}
52
- onCreated={refreshPages}
53
46
  onError={setError}
54
47
  />
55
48
  );
package/src/types.ts CHANGED
@@ -146,12 +146,18 @@ export interface CmsCapabilities {
146
146
  defaultLocale: string;
147
147
  /** More than one declared locale. Gates the whole i18n surface. */
148
148
  multilingual: boolean;
149
+ /** The server's `listPages` understands `contentType` — the licence to give each content
150
+ * type its own tab and its own list. An older server ACCEPTS the argument and ignores it
151
+ * (an unknown input key is passed through, not rejected), answering with the pooled list:
152
+ * without this probe the editor renders N tabs that all show every type's pages under a
153
+ * heading claiming otherwise, and "New page" from any of them stamps that tab's type. */
154
+ pagesByType: boolean;
149
155
  }
150
156
 
151
157
  /** Used until `listCmsCapabilities` answers, and when it cannot (an older server). Assumes
152
158
  * MONOLINGUAL: a hidden i18n surface on a multilingual site is recoverable by reloading,
153
159
  * where a half-rendered one on a single-locale site is what this replaced. */
154
- export const DEFAULT_CAPABILITIES: CmsCapabilities = { locales: ["en"], defaultLocale: "en", multilingual: false };
160
+ export const DEFAULT_CAPABILITIES: CmsCapabilities = { locales: ["en"], defaultLocale: "en", multilingual: false, pagesByType: false };
155
161
 
156
162
  /** Mirror of @pramen/cms `CollectionFeature`. */
157
163
  export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";