@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.56",
3
+ "version": "0.0.57",
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
@@ -37,6 +37,10 @@ export function isTokenExpired(token: string): boolean {
37
37
 
38
38
  const LS = "pramen.cmsEditor";
39
39
 
40
+ /** The columns the page LIST renders — id (to open the row), title, slug, locale, status.
41
+ * `typeId` rides along so a caller can tell which type a row belongs to without a join. */
42
+ export const PAGE_LIST_COLUMNS = ["id", "typeId", "title", "slug", "locale", "status"] as const;
43
+
40
44
  /**
41
45
  * The session for this page load.
42
46
  *
@@ -132,10 +136,19 @@ export class Api {
132
136
  listBlockTypes = () => this.call<BlockType[]>("listBlockTypes");
133
137
  listContentTypes = () => this.call<ContentType[]>("listContentTypes");
134
138
  getContentType = (id: string) => this.call<ContentType | null>("getContentType", { id });
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);
139
+ /** All pages, or just one content type's (by SLUG), one page of them at a time. The server
140
+ * does the filtering AND the paging — see `listPages` in @pramen/cms; it caps its result,
141
+ * so narrowing or counting client-side would be narrowing an already-truncated list.
142
+ *
143
+ * `PAGE_LIST_COLUMNS` is the projection the list screen actually renders. The full page row
144
+ * carries every SEO column, the schedule stamps and the whole `fields` bag; asking for all
145
+ * of it to draw five columns is the D1-over-RPC shape that made lists hang (GitHub #22).
146
+ * The editor fetches a whole page by id (`getPageById`) when it needs the wide row. */
147
+ listPages = (opts: { contentType?: string; limit?: number; offset?: number } = {}) =>
148
+ this.call<Page[]>("listPages", { ...opts, select: [...PAGE_LIST_COLUMNS] });
149
+ /** One page, wide, by id — what the page editor opens. Resolving an id against `listPages`
150
+ * instead can miss: that list is capped, and the editor lists one content type per tab. */
151
+ getPageById = (pageId: string) => this.call<Page | null>("getPageById", { pageId });
139
152
  getPagePreview = (slug: string, locale?: string) => this.call<AssembledPage>("getPage", { slug, locale, preview: true });
140
153
  listPageAudit = (pageId: string) => this.call<AuditEntry[]>("listPageAudit", { pageId });
141
154
 
@@ -95,8 +95,21 @@ interface AppContextValue {
95
95
  collections: CollectionMeta[];
96
96
  /** Content types registered on the server (from `listContentTypes`). More than one ⇒ each
97
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[];
98
+ * page and an article sit in the same column with nothing to tell them apart.
99
+ *
100
+ * `null` means NOT ANSWERED YET, which is a different thing from "this deployment has
101
+ * none" and has to be told apart from it: the whole nav shape is decided by the count, so
102
+ * an empty array standing in for "still loading" makes every screen paint the single-type
103
+ * layout first and correct itself a round trip later. It also swallowed failure — a 5xx,
104
+ * or a session whose role cannot call `listContentTypes` — into the same value, leaving a
105
+ * type route showing "Loading…" over data it already had, forever. */
106
+ contentTypes: ContentType[] | null;
107
+ /** The `listContentTypes` call FAILED (as opposed to answering with none). Screens that
108
+ * cannot render without it say so and offer `refreshContentTypes` rather than pretending
109
+ * to still be loading. */
110
+ contentTypesFailed: boolean;
111
+ /** Retry `listContentTypes` — wired to the retry button on those screens. */
112
+ refreshContentTypes: () => void;
100
113
  /** What the SERVER says this deployment supports (from `listCmsCapabilities`) — today,
101
114
  * its declared locales. The editor renders its i18n surface off this rather than a local
102
115
  * flag, so the UI and the data can never disagree about whether the site is multilingual. */
@@ -127,7 +140,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
127
140
  const [cfg, setCfg] = useState<Config>(() => loadConfig(BACKEND));
128
141
  const [me, setMe] = useState<Me | null>(null);
129
142
  const [collections, setCollections] = useState<CollectionMeta[]>([]);
130
- const [contentTypes, setContentTypes] = useState<ContentType[]>([]);
143
+ const [contentTypes, setContentTypes] = useState<ContentType[] | null>(null);
144
+ const [contentTypesFailed, setContentTypesFailed] = useState(false);
145
+ const [contentTypesNonce, setContentTypesNonce] = useState(0);
146
+ const refreshContentTypes = useCallback(() => setContentTypesNonce((n) => n + 1), []);
131
147
  const [cms, setCms] = useState<CmsCapabilities>(DEFAULT_CAPABILITIES);
132
148
  const [error, setError] = useState("");
133
149
  // A usable session = somewhere to call + a token that is NOT expired. An expired token
@@ -185,14 +201,31 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
185
201
  // Collections drive the nav + list/edit routes. An app that registers none (or an older
186
202
  // server without the handler) just leaves the nav as-is — a failure is non-fatal.
187
203
  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([]));
191
204
  // A server older than this handler leaves the monolingual default, which is the safe
192
- // way round: the i18n surface stays hidden rather than half-rendered.
193
- api.call<CmsCapabilities>("listCmsCapabilities").then(setCms).catch(() => setCms(DEFAULT_CAPABILITIES));
205
+ // way round: the i18n surface stays hidden rather than half-rendered. Merged OVER the
206
+ // defaults, not substituted for them, so a capability the server does not know about
207
+ // reads as its (closed) default rather than `undefined`.
208
+ api
209
+ .call<Partial<CmsCapabilities>>("listCmsCapabilities")
210
+ .then((r) => setCms({ ...DEFAULT_CAPABILITIES, ...r }))
211
+ .catch(() => setCms(DEFAULT_CAPABILITIES));
194
212
  }, [api, authValid]);
195
213
 
214
+ // Drives the per-type nav + list routes. A failure falls back to the single pooled "Pages"
215
+ // tab — the layout before types had tabs of their own — but is RECORDED, so a screen that
216
+ // genuinely cannot render without the list (the per-type route) can say the call failed and
217
+ // offer a retry instead of claiming to still be loading. Its own effect so that retry
218
+ // re-runs THIS call and not `me`/`listCollections`/the capability probe alongside it.
219
+ useEffect(() => {
220
+ if (!authValid) return;
221
+ let live = true;
222
+ api
223
+ .listContentTypes()
224
+ .then((r) => { if (live) { setContentTypes(r); setContentTypesFailed(false); } })
225
+ .catch(() => { if (live) { setContentTypes([]); setContentTypesFailed(true); } });
226
+ return () => { live = false; };
227
+ }, [api, authValid, contentTypesNonce]);
228
+
196
229
  if (!authValid) {
197
230
  // Configured external sign-in → hand off (the boot effect above navigates). Otherwise
198
231
  // fall back to the built-in Setup screen (paste a JWT).
@@ -206,6 +239,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
206
239
  isAdmin: (me?.roles ?? []).includes("admin"),
207
240
  collections,
208
241
  contentTypes,
242
+ contentTypesFailed,
243
+ refreshContentTypes,
209
244
  cms,
210
245
  error,
211
246
  setError,
@@ -9,7 +9,7 @@ import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput }
9
9
  import type { Config } from "./api";
10
10
  import { useApp, type Me } from "./app-context";
11
11
  import { isRichTextDoc, richTextToPlainText } from "./rich-text";
12
- import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
12
+ import type { AssembledPage, AuditEntry, BlockType, CmsCapabilities, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
13
13
 
14
14
  export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
15
15
  export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
@@ -21,6 +21,31 @@ export function visibleTabs(multilingual: boolean): InspectorTab[] {
21
21
  return multilingual ? INSPECTOR_TABS : INSPECTOR_TABS.filter((t) => t !== "i18n");
22
22
  }
23
23
 
24
+ /** Collections-only deployments hide the block/page builder entirely. Read in one place so
25
+ * the nav, the landing redirect and the per-type route cannot disagree about it — a deep
26
+ * link to `/types/:slug` used to render the very builder this flag exists to hide. */
27
+ export function pagesHidden(): boolean {
28
+ return typeof window !== "undefined" && window.PRAMEN_CMS_EDITOR?.hidePages === true;
29
+ }
30
+
31
+ /**
32
+ * Does this deployment get one tab and one list PER CONTENT TYPE?
33
+ *
34
+ * ONE definition, used by the tab bar, the landing redirect and the page editor's back
35
+ * target — the same reason `visibleTabs` exists. Three places re-deriving the whole nav
36
+ * shape is three places that can disagree about which screen `/` is.
37
+ *
38
+ * - `null` content types mean NOT ANSWERED YET, and answer `false` without committing: the
39
+ * pooled list is what a single-type deployment keeps, so painting it before the count
40
+ * arrives is a flash of the exact screen the split exists to retire.
41
+ * - `pagesByType` is the SERVER's declaration that `listPages` understands `contentType`
42
+ * (see `CmsCapabilities`). Without it the split fails open: N tabs, each showing every
43
+ * type's pages under a heading naming one.
44
+ */
45
+ export function splitsByType(contentTypes: ContentType[] | null, cms: CmsCapabilities, hidePages = pagesHidden()): boolean {
46
+ return !hidePages && cms.pagesByType && contentTypes !== null && contentTypes.length > 1;
47
+ }
48
+
24
49
  // --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
25
50
 
26
51
  const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
@@ -106,19 +131,88 @@ function Banner({ ok, children }: { ok?: boolean; children: ReactNode }) {
106
131
 
107
132
  const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-subtle">{children}</span>;
108
133
 
134
+ /** A one-line neutral panel with an optional way out — the loading / not-found / unknown-slug
135
+ * state every route needs before (or instead of) its real screen. Four routes had it written
136
+ * out verbatim; the copies had already drifted on which of them offered a way back. */
137
+ export function Notice({ children, action }: { children: ReactNode; action?: ReactNode }) {
138
+ return (
139
+ <div className="mx-auto flex max-w-[1200px] items-center gap-2 px-7 pt-8">
140
+ <p className="text-fg-subtle">{children}</p>
141
+ {action}
142
+ </div>
143
+ );
144
+ }
145
+
109
146
  // --- pages list --------------------------------------------------------------
110
147
 
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 }) {
148
+ /** Page size for the page list. `listPages` caps server-side, so a request without an
149
+ * explicit limit silently truncates and the header then reports the truncated count as if
150
+ * it were the total, which reads as "that is all there is". Same reason, same answer, as
151
+ * `COLLECTION_PAGE_SIZE` below. */
152
+ const PAGE_LIST_SIZE = 50;
153
+
154
+ /** The page list, loading its own rows. `type` scopes it to one content type: the fetch asks
155
+ * the SERVER for that type (narrowing a capped list here would drop the tail of every type),
156
+ * the heading is the type's name, and the create modal opens on that type instead of asking
157
+ * again. Omitted ⇒ the pooled list over every type, which is what a single-type deployment
158
+ * should keep seeing — byte for byte, including the wording.
159
+ *
160
+ * It owns the fetching (like `CollectionList`) rather than taking rows as a prop, because
161
+ * the two things that go wrong when a route owns them both go wrong invisibly: rows left
162
+ * standing from the previous type while the heading has already flipped to the new one, and
163
+ * a count that is really the server's cap. */
164
+ export function PageList({ api, type, onOpen, onError }: { api: Api; type?: ContentType; onOpen: (p: Page) => void; onError: (s: string) => void }) {
116
165
  // From the SERVER (listCmsCapabilities), not a local flag — see `CmsCapabilities`.
117
166
  const { cms: { multilingual } } = useApp();
167
+ const [pages, setPages] = useState<Page[]>([]);
168
+ const [offset, setOffset] = useState(0);
169
+ const [hasMore, setHasMore] = useState(false);
170
+ const [loading, setLoading] = useState(true);
171
+ const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
118
172
  const [creating, setCreating] = useState(false);
173
+ const contentType = type?.slug;
174
+
175
+ const load = useCallback(
176
+ (off: number) => {
177
+ setLoading(true);
178
+ return api
179
+ .listPages({ contentType, limit: PAGE_LIST_SIZE, offset: off })
180
+ .then((r) => {
181
+ setPages((prev) => (off === 0 ? r : [...prev, ...r]));
182
+ // A full page means there is probably more; a short one is definitely the end.
183
+ setHasMore(r.length === PAGE_LIST_SIZE);
184
+ setOffset(off + r.length);
185
+ })
186
+ .catch((e) => onError(errMsg(e)))
187
+ .finally(() => setLoading(false));
188
+ },
189
+ [api, contentType, onError],
190
+ );
191
+
192
+ // Clearing first is the point: buzola renders the same component instance across a
193
+ // params-only change (`/types/a` → `/types/b`), so without this the previous type's rows
194
+ // sit under the new type's heading until the fetch lands — and permanently if it fails.
195
+ useEffect(() => {
196
+ setPages([]);
197
+ setOffset(0);
198
+ setHasMore(false);
199
+ void load(0);
200
+ }, [load]);
201
+
202
+ // Only for the empty state's hint, and it is a GLOBAL list — keyed on `api` alone so
203
+ // switching type tabs doesn't refetch it.
204
+ useEffect(() => {
205
+ api.listBlockTypes().then(setBlockTypes).catch((e) => onError(errMsg(e)));
206
+ }, [api, onError]);
207
+
208
+ // "pages", not "entries": these are pages of a content type, and a single-type deployment
209
+ // must read exactly as it did before types had tabs. `type.name` is a label a host writes
210
+ // (often plural, it labels the tab), so it heads the screen and is never bent into a noun
211
+ // phrase — "+ New Articles" is what guessing at grammar produces.
212
+ const count = pages.length === 0 ? "None yet" : pages.length === 1 ? "1 page total" : `${pages.length}${hasMore ? "+" : ""} pages total`;
119
213
  return (
120
214
  <>
121
- <Hero lead={type?.name ?? "Pages"} em={pages.length === 0 ? "None yet" : pages.length === 1 ? "1 entry total" : `${pages.length} entries total`}>
215
+ <Hero lead={type?.name ?? "Pages"} em={loading && pages.length === 0 ? "Loading…" : count}>
122
216
  <Cta text="Let's" em="create something">
123
217
  <Button className="shrink-0" onPress={() => setCreating(true)}>+ New page</Button>
124
218
  </Cta>
@@ -133,26 +227,37 @@ export function PageList({ api, pages, blockTypes, type, onOpen, onCreated, onEr
133
227
  <Pill status={p.status}>{p.status}</Pill>
134
228
  </div>
135
229
  ))}
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}
230
+ {!loading && 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}
137
231
  </div>
232
+ {hasMore ? (
233
+ <div className="mt-4 flex justify-center">
234
+ <Button variant="ghost" onPress={() => void load(offset)} isDisabled={loading}>{loading ? "Loading…" : "Load more"}</Button>
235
+ </div>
236
+ ) : null}
138
237
  </div>
139
- {creating ? <CreatePage api={api} type={type} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); onCreated(); }} onError={onError} /> : null}
238
+ {creating ? <CreatePage api={api} type={type} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); void load(0); }} onError={onError} /> : null}
140
239
  </>
141
240
  );
142
241
  }
143
242
 
144
243
  function CreatePage({ api, type, onClose, onCreated, onError }: { api: Api; type?: ContentType; onClose: () => void; onCreated: () => void; onError: (s: string) => void }) {
145
- const [cts, setCts] = useState<ContentType[]>([]);
244
+ // The app context already holds this list — a second fetch per modal open is a second
245
+ // cache of one list in one tree, with its own error policy.
246
+ const { contentTypes } = useApp();
247
+ const cts = contentTypes ?? [];
146
248
  const [typeId, setTypeId] = useState(type?.id ?? "");
147
249
  const [title, setTitle] = useState("");
148
250
  const [slug, setSlug] = useState("");
251
+ // Re-sync, not seed-once. On a type-scoped list the type is decided by the screen you are
252
+ // on — and that screen can change UNDER an open modal: history navigation isn't blocked by
253
+ // the overlay the way a topbar click is, and buzola keeps this component instance across
254
+ // it. Seeded once, the modal kept filing the new screen's page under the old screen's type,
255
+ // with the picker hidden so nothing on screen said so and `createPage` trusting the id.
149
256
  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;
154
- api.listContentTypes().then((r) => { setCts(r); if (r[0]) setTypeId(r[0].id); }).catch((e) => onError(errMsg(e)));
155
- }, [api, type, onError]);
257
+ if (type) { setTypeId(type.id); return; }
258
+ const list = contentTypes ?? [];
259
+ setTypeId((cur) => (cur && list.some((c) => c.id === cur) ? cur : list[0]?.id ?? ""));
260
+ }, [type, contentTypes]);
156
261
  const create = async () => {
157
262
  try {
158
263
  await api.call("createPage", { typeId, title, slug: slug || slugify(title) });
@@ -163,7 +268,13 @@ function CreatePage({ api, type, onClose, onCreated, onError }: { api: Api; type
163
268
  };
164
269
  return (
165
270
  <Modal onClose={onClose}>
166
- <ModalTitle>Create a <Dim>new page</Dim> and define the essentials<Dim>.</Dim></ModalTitle>
271
+ {/* Name the type when the picker is hidden. Otherwise the whole modal says "page" and
272
+ nothing on it says WHICH type the page is being filed under — on a per-type list
273
+ that is the one fact the screen is supposed to be carrying. */}
274
+ <ModalTitle>
275
+ Create a <Dim>new page</Dim>
276
+ {type ? <> in <Dim>{type.name}</Dim></> : null} and define the essentials<Dim>.</Dim>
277
+ </ModalTitle>
167
278
  <div className="flex flex-col gap-4">
168
279
  <div className={`w-full flex-col gap-2 ${type ? "hidden" : "flex"}`}>
169
280
  <span className="text-sm font-medium text-fg">Content type</span>
@@ -7,12 +7,35 @@ 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 { pagesHidden, splitsByType } from "../components";
10
11
  import { opensInSameTab } from "../mount";
11
12
 
12
13
  const THEME_KEY = "pramen.cms.theme";
13
14
 
15
+ /**
16
+ * The slug segment under `prefix`, DECODED.
17
+ *
18
+ * `useRoute().pathname` comes off a `URL`, so it is percent-encoded; the slugs it is compared
19
+ * against are the raw values the server stored. buzola encodes when it builds an href and
20
+ * decodes into `params` when it matches, so routing is unaffected — only this comparison was,
21
+ * and a content type called `články` navigated correctly to a tab bar with nothing lit.
22
+ *
23
+ * A malformed sequence (`%zz`) throws in `decodeURIComponent`; that cannot match any slug
24
+ * either way, so it degrades to no highlight rather than tearing down the chrome.
25
+ */
26
+ export function segmentAt(pathname: string, prefix: string): string | undefined {
27
+ if (!pathname.startsWith(prefix)) return undefined;
28
+ const raw = pathname.slice(prefix.length).split("/")[0];
29
+ if (!raw) return undefined;
30
+ try {
31
+ return decodeURIComponent(raw);
32
+ } catch {
33
+ return raw;
34
+ }
35
+ }
36
+
14
37
  export default function RootLayout() {
15
- const { isAdmin, collections, contentTypes, error, reconfigure, confirmNavigation } = useApp();
38
+ const { isAdmin, collections, contentTypes, cms, error, reconfigure, confirmNavigation } = useApp();
16
39
  const navigate = useNavigate();
17
40
  const { pathname } = useRoute();
18
41
 
@@ -34,9 +57,9 @@ export default function RootLayout() {
34
57
  }, [theme]);
35
58
 
36
59
  // The active collection slug, if we're under /collections/:slug(/...).
37
- const collectionSlug = pathname.startsWith("/collections/") ? pathname.split("/")[2] : undefined;
60
+ const collectionSlug = segmentAt(pathname, "/collections/");
38
61
  // …and the active content type, under /types/:slug.
39
- const typeSlug = pathname.startsWith("/types/") ? pathname.split("/")[2] : undefined;
62
+ const typeSlug = segmentAt(pathname, "/types/");
40
63
 
41
64
  // "Pages" stays lit while editing a page (/pages/:id) too — but only on a deployment that
42
65
  // still HAS a pooled Pages tab. Split by type, the page editor lights nothing: the route
@@ -50,12 +73,20 @@ export default function RootLayout() {
50
73
  : pathname.startsWith("/settings") ? "settings"
51
74
  : "";
52
75
 
53
- const tabCls = (key: string) => (active === key ? "bg-surface-muted text-fg" : "text-fg-muted");
76
+ // `aria-current` alongside the class: split by type the nav is N mutually-exclusive tabs
77
+ // whose only "you are here" cue is a background tint, which is invisible to a screen reader
78
+ // and marginal for anyone who cannot see the tint.
79
+ const tabProps = (key: string) => ({
80
+ className: active === key ? "bg-surface-muted text-fg" : "text-fg-muted",
81
+ ...(active === key ? { "aria-current": "page" as const } : {}),
82
+ });
54
83
 
55
84
  // Host-configured links to companion tools (e.g. a curation page), from /config.js.
56
85
  const extraNav = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.extraNav ?? [] : [];
57
86
  // Collections-only deployments hide the block/page builder entirely.
58
- const hidePages = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.hidePages === true : false;
87
+ const hidePages = pagesHidden();
88
+ // Same rule as the landing redirect and the page editor's back target — see `splitsByType`.
89
+ const splitByType = splitsByType(contentTypes, cms, hidePages);
59
90
 
60
91
  // See the extraNav comment below. The rules live in `mount.ts` beside the containment they
61
92
  // depend on; what this supplies is the URL the BROWSER will resolve a relative href
@@ -89,38 +120,38 @@ export default function RootLayout() {
89
120
  the plain "Pages" tab — there is nothing there to separate, and splitting it
90
121
  would put the deployment's own type name where a generic label reads better.
91
122
  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) => (
123
+ {hidePages ? null : splitByType ? (
124
+ (contentTypes ?? []).map((t) => (
94
125
  <Button
95
126
  key={t.slug}
96
127
  variant="ghost"
97
128
  size="sm"
98
- className={tabCls(`type:${t.slug}`)}
129
+ {...tabProps(`type:${t.slug}`)}
99
130
  onPress={guarded(() => navigate("type", { params: { slug: t.slug } }))}
100
131
  >
101
132
  {t.name}
102
133
  </Button>
103
134
  ))
104
135
  ) : (
105
- <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={guarded(() => navigate("home"))}>
136
+ <Button variant="ghost" size="sm" {...tabProps("pages")} onPress={guarded(() => navigate("home"))}>
106
137
  Pages
107
138
  </Button>
108
139
  )}
109
140
  {collections.map((c) => (
110
- <Button key={c.slug} variant="ghost" size="sm" className={tabCls(`col:${c.slug}`)} onPress={guarded(() => navigate("collection", { params: { slug: c.slug } }))}>
141
+ <Button key={c.slug} variant="ghost" size="sm" {...tabProps(`col:${c.slug}`)} onPress={guarded(() => navigate("collection", { params: { slug: c.slug } }))}>
111
142
  {c.icon ? `${c.icon} ` : ""}
112
143
  {c.pluralLabel}
113
144
  </Button>
114
145
  ))}
115
- <Button variant="ghost" size="sm" className={tabCls("media")} onPress={guarded(() => navigate("media"))}>
146
+ <Button variant="ghost" size="sm" {...tabProps("media")} onPress={guarded(() => navigate("media"))}>
116
147
  Media
117
148
  </Button>
118
149
  {isAdmin ? (
119
- <Button variant="ghost" size="sm" className={tabCls("users")} onPress={guarded(() => navigate("users"))}>
150
+ <Button variant="ghost" size="sm" {...tabProps("users")} onPress={guarded(() => navigate("users"))}>
120
151
  Users
121
152
  </Button>
122
153
  ) : null}
123
- <Button variant="ghost" size="sm" className={tabCls("settings")} onPress={guarded(() => navigate("settings"))}>
154
+ <Button variant="ghost" size="sm" {...tabProps("settings")} onPress={guarded(() => navigate("settings"))}>
124
155
  Settings
125
156
  </Button>
126
157
  {extraNav.map((l) => (
@@ -5,7 +5,7 @@
5
5
  import { createPage, useNavigate } from "@buzola/router";
6
6
  import { Button } from "@podoba/react";
7
7
  import { useApp } from "../app-context";
8
- import { CollectionEditor } from "../components";
8
+ import { CollectionEditor, Notice } from "../components";
9
9
 
10
10
  export default createPage()
11
11
  .params({ slug: "string", id: "string" })
@@ -18,10 +18,9 @@ export default createPage()
18
18
 
19
19
  if (!def) {
20
20
  return (
21
- <div className="mx-auto flex max-w-[1200px] items-center gap-2 px-7 pt-8">
22
- <p className="text-fg-subtle">{collections.length === 0 ? "Loading…" : `Unknown collection: ${params.slug}`}</p>
23
- {collections.length > 0 ? <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Pages</Button> : null}
24
- </div>
21
+ <Notice action={collections.length > 0 ? <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Pages</Button> : undefined}>
22
+ {collections.length === 0 ? "Loading…" : `Unknown collection: ${params.slug}`}
23
+ </Notice>
25
24
  );
26
25
  }
27
26
  return (
@@ -5,7 +5,7 @@
5
5
  import { createPage, useNavigate } from "@buzola/router";
6
6
  import { Button } from "@podoba/react";
7
7
  import { useApp } from "../app-context";
8
- import { CollectionList } from "../components";
8
+ import { CollectionList, Notice } from "../components";
9
9
 
10
10
  export default createPage()
11
11
  .params({ slug: "string" })
@@ -18,10 +18,9 @@ export default createPage()
18
18
  if (!def) {
19
19
  // Collections load async; before they arrive (or for a bad slug) show a neutral state.
20
20
  return (
21
- <div className="mx-auto flex max-w-[1200px] items-center gap-2 px-7 pt-8">
22
- <p className="text-fg-subtle">{collections.length === 0 ? "Loading…" : `Unknown collection: ${params.slug}`}</p>
23
- {collections.length > 0 ? <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Pages</Button> : null}
24
- </div>
21
+ <Notice action={collections.length > 0 ? <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Pages</Button> : undefined}>
22
+ {collections.length === 0 ? "Loading…" : `Unknown collection: ${params.slug}`}
23
+ </Notice>
25
24
  );
26
25
  }
27
26
  return (
@@ -1,56 +1,55 @@
1
1
  // Home route (`/`): the page list. Opening a page navigates to /pages/:pageId.
2
2
 
3
3
  import { createPage, useNavigate } from "@buzola/router";
4
- import { useEffect, useState } from "react";
4
+ import { useEffect } from "react";
5
5
  import { useApp } from "../app-context";
6
- import { PageList, errMsg } from "../components";
7
- import type { BlockType, Page } from "../types";
6
+ import { PageList, pagesHidden, splitsByType } from "../components";
8
7
 
9
8
  export default createPage()
10
9
  .route("/")
11
10
  .render(function Home() {
12
- const { api, setError, collections, contentTypes } = useApp();
11
+ const { api, setError, collections, contentTypes, cms } = useApp();
13
12
  const navigate = useNavigate();
14
- const [pages, setPages] = useState<Page[]>([]);
15
- const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
16
13
 
17
14
  // Collections-only deployment: `/` is the Pages list, so land on the first
18
15
  // collection instead. Without this, hiding the tab still leaves the landing page
19
16
  // showing an empty page list — and still fetching pages, which errors outright on a
20
17
  // deployment that never spread cmsSchema.
21
- const hidePages = typeof window !== "undefined" && window.PRAMEN_CMS_EDITOR?.hidePages === true;
18
+ const hidePages = pagesHidden();
22
19
  const firstCollection = collections[0]?.slug;
23
20
  // With more than one content type each gets its own tab and its own list (`/types/:slug`),
24
21
  // so the pooled list here has no tab of its own to be reached from and would just be a
25
22
  // fourth way to see the same rows. Land on the first type instead. One type (or none, on a
26
23
  // 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;
24
+ const splitByType = splitsByType(contentTypes, cms, hidePages);
25
+ // A content type must have a non-empty slug (the server refuses one), but this is data
26
+ // from a server this build does not control, and an empty slug builds `/types/` — a path
27
+ // the router drops the empty segment from, so it matches nothing. Skip such a type rather
28
+ // than redirect into a route that cannot match.
29
+ const firstType = (contentTypes ?? []).find((t) => t.slug !== "")?.slug;
30
+ // ONE condition for the redirect and for the bail below. Gated differently, a split
31
+ // deployment whose first type has no usable slug redirected nowhere and rendered nothing:
32
+ // a permanently blank `/`, which the wordmark leads straight back to.
33
+ const toType = splitByType && firstType !== undefined ? firstType : undefined;
29
34
 
30
35
  useEffect(() => {
31
36
  if (hidePages && firstCollection) navigate("collection", { params: { slug: firstCollection }, replace: true });
32
- else if (splitByType && firstType) navigate("type", { params: { slug: firstType }, replace: true });
33
- }, [hidePages, firstCollection, splitByType, firstType, navigate]);
37
+ else if (toType !== undefined) navigate("type", { params: { slug: toType }, replace: true });
38
+ }, [hidePages, firstCollection, toType, navigate]);
34
39
 
35
- const refreshPages = () => api.listPages().then(setPages).catch((e) => setError(errMsg(e)));
36
- useEffect(() => {
37
- if (hidePages || splitByType) return;
38
- refreshPages();
39
- api.listBlockTypes().then(setBlockTypes).catch((e) => setError(errMsg(e)));
40
- // eslint-disable-next-line react-hooks/exhaustive-deps
41
- }, [api, hidePages, splitByType]);
42
-
43
- if (hidePages && firstCollection) return null;
44
- if (splitByType) return null;
40
+ // `hidePages` means this deployment has no block/page builder at all, so `/` never falls
41
+ // through to the page list here — fetching pages is exactly what the flag says not to do
42
+ // (on a deployment that never spread `cmsSchema` it errors outright). It hands off to the
43
+ // first collection, or says so when there is none to hand off to.
44
+ // (Nothing rendered when there is no collection either: `collections` is empty both while
45
+ // it loads and when a deployment registers none, so any message here would be wrong half
46
+ // the time. The layout's chrome is still on screen — the tabs are the way out.)
47
+ if (hidePages) return null;
48
+ if (toType !== undefined) return null;
49
+ // Not answered yet ≠ "this deployment has one type". Rendering the pooled list before
50
+ // `listContentTypes` lands paints — and fetches — the very screen the split exists to
51
+ // retire, then redirects away from it a round trip later.
52
+ if (contentTypes === null) return null;
45
53
 
46
- return (
47
- <PageList
48
- api={api}
49
- pages={pages}
50
- blockTypes={blockTypes}
51
- onOpen={(p) => navigate("page", { params: { pageId: p.id } })}
52
- onCreated={refreshPages}
53
- onError={setError}
54
- />
55
- );
54
+ return <PageList api={api} onOpen={(p) => navigate("page", { params: { pageId: p.id } })} onError={setError} />;
56
55
  });