@pramen/cms-editor 0.0.35 → 0.0.36

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.35",
3
+ "version": "0.0.36",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,6 +6,7 @@
6
6
  import { Button, Input } from "@podoba/react";
7
7
  import { createContext, use, useEffect, useMemo, useState } from "react";
8
8
  import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
9
+ import type { CollectionMeta } from "./types";
9
10
 
10
11
  declare global {
11
12
  interface Window {
@@ -45,6 +46,9 @@ interface AppContextValue {
45
46
  cfg: Config;
46
47
  me: Me | null;
47
48
  isAdmin: boolean;
49
+ /** Collections registered on the server (from `listCollections`) — drives the nav + the
50
+ * generic list/edit routes. Empty when the server registers none. */
51
+ collections: CollectionMeta[];
48
52
  error: string;
49
53
  setError: (s: string) => void;
50
54
  /** Sign out — drop the token so the config gate falls back to Setup. */
@@ -62,6 +66,7 @@ export function useApp(): AppContextValue {
62
66
  export function AppProvider({ children }: { children: React.ReactNode }) {
63
67
  const [cfg, setCfg] = useState<Config>(loadConfig());
64
68
  const [me, setMe] = useState<Me | null>(null);
69
+ const [collections, setCollections] = useState<CollectionMeta[]>([]);
65
70
  const [error, setError] = useState("");
66
71
  // A usable session = a base URL + a token that is NOT expired. An expired token counts as
67
72
  // no session: otherwise the editor mounts and every RPC 403s into an error banner.
@@ -91,6 +96,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
91
96
  if (!authValid) return;
92
97
  // `me` gates the Users tab + drives Settings — a failing call is fine (leaves it {}).
93
98
  api.call<Me>("me").then(setMe).catch(() => setMe({}));
99
+ // Collections drive the nav + list/edit routes. An app that registers none (or an older
100
+ // server without the handler) just leaves the nav as-is — a failure is non-fatal.
101
+ api.call<CollectionMeta[]>("listCollections").then(setCollections).catch(() => setCollections([]));
94
102
  }, [api, authValid]);
95
103
 
96
104
  if (!authValid) {
@@ -104,6 +112,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
104
112
  cfg,
105
113
  me,
106
114
  isAdmin: (me?.roles ?? []).includes("admin"),
115
+ collections,
107
116
  error,
108
117
  setError,
109
118
  reconfigure: () => {
package/src/buzola.gen.ts CHANGED
@@ -6,16 +6,20 @@ import { buildRouteTree } from '@buzola/router';
6
6
  import type { RouteConfig } from '@buzola/router';
7
7
 
8
8
  import Route0 from './routes/_layout';
9
- const Route1 = () => import('./routes/home').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
10
- const Route2 = () => import('./routes/media').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
11
- const Route3 = () => import('./routes/page').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
12
- const Route4 = () => import('./routes/settings').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
13
- const Route5 = () => import('./routes/users').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
14
- const Route6 = () => import('./routes/_404').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
9
+ const Route1 = () => import('./routes/collection-item').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
10
+ const Route2 = () => import('./routes/collection').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
11
+ const Route3 = () => import('./routes/home').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
12
+ const Route4 = () => import('./routes/media').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
13
+ const Route5 = () => import('./routes/page').then(m => ({ default: m.default.component })) as Promise<{ default: ComponentType }>;
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
17
 
16
18
  // Module augmentation for type-safe page-centric routing
17
19
  declare module '@buzola/router' {
18
20
  interface BuzolaPageMap {
21
+ 'collection-item': { slug: string; id: string };
22
+ 'collection': { slug: string };
19
23
  'home': {};
20
24
  'media': {};
21
25
  'page': { pageId: string; tab?: string };
@@ -26,6 +30,8 @@ declare module '@buzola/router' {
26
30
 
27
31
  // Page registry — page ID → route pattern
28
32
  export const pageRegistry: Record<string, string> = {
33
+ 'collection-item': '/collections/:slug/:id',
34
+ 'collection': '/collections/:slug',
29
35
  'home': '/',
30
36
  'media': '/media',
31
37
  'page': '/pages/:pageId',
@@ -40,37 +46,49 @@ const routeConfigs: RouteConfig[] = [
40
46
  isLayout: true,
41
47
  children: [
42
48
  {
43
- path: '/home',
44
- matchPath: '/',
49
+ path: '/collection-item',
50
+ matchPath: '/collections/:slug/:id',
45
51
  component: lazy(Route1),
46
52
  preload: Route1,
47
53
  },
48
54
  {
49
- path: '/media',
55
+ path: '/collection',
56
+ matchPath: '/collections/:slug',
50
57
  component: lazy(Route2),
51
58
  preload: Route2,
52
59
  },
53
60
  {
54
- path: '/page',
55
- matchPath: '/pages/:pageId',
61
+ path: '/home',
62
+ matchPath: '/',
56
63
  component: lazy(Route3),
57
64
  preload: Route3,
58
65
  },
59
66
  {
60
- path: '/settings',
67
+ path: '/media',
61
68
  component: lazy(Route4),
62
69
  preload: Route4,
63
70
  },
64
71
  {
65
- path: '/users',
72
+ path: '/page',
73
+ matchPath: '/pages/:pageId',
66
74
  component: lazy(Route5),
67
75
  preload: Route5,
68
76
  },
69
77
  {
70
- path: '/:__notFound+',
78
+ path: '/settings',
71
79
  component: lazy(Route6),
72
80
  preload: Route6,
73
81
  },
82
+ {
83
+ path: '/users',
84
+ component: lazy(Route7),
85
+ preload: Route7,
86
+ },
87
+ {
88
+ path: '/:__notFound+',
89
+ component: lazy(Route8),
90
+ preload: Route8,
91
+ },
74
92
  ],
75
93
  },
76
94
  ];
@@ -8,7 +8,7 @@ import { Api, ApiError } from "./api";
8
8
  import { FieldForm } from "./fields";
9
9
  import type { Config } from "./api";
10
10
  import type { Me } from "./app-context";
11
- import type { AssembledPage, AuditEntry, BlockType, ContentType, FieldDefinition, Media, Page, RegionDefinition, RenderedBlock } from "./types";
11
+ import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, Media, Page, RegionDefinition, RenderedBlock } from "./types";
12
12
 
13
13
  export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
14
14
  export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
@@ -171,6 +171,144 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
171
171
  );
172
172
  }
173
173
 
174
+ // --- collections -------------------------------------------------------------
175
+ //
176
+ // Generic list + edit views over a host-app entity registered as a collection. Both are
177
+ // driven entirely by the CollectionMeta fetched from `listCollections` — one editor, N
178
+ // collections, zero per-collection code. Rows are addressed by `def.idField` (the entity's
179
+ // PK column, defaults "id"); the server resolves the real PK from the value.
180
+
181
+ /** Render a list-cell value as a short string (objects/arrays are summarized, not dumped). */
182
+ function cellText(v: unknown): string {
183
+ if (v == null) return "";
184
+ if (typeof v === "boolean") return v ? "yes" : "no";
185
+ if (Array.isArray(v)) return v.length === 1 ? "1 item" : `${v.length} items`;
186
+ if (typeof v === "object") return "—";
187
+ return String(v);
188
+ }
189
+
190
+ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api; def: CollectionMeta; onOpen: (id: string) => void; onNew: () => void; onError: (s: string) => void }) {
191
+ const [rows, setRows] = useState<Record<string, unknown>[]>([]);
192
+ const [loading, setLoading] = useState(true);
193
+ useEffect(() => {
194
+ let live = true;
195
+ setLoading(true);
196
+ api.call<Record<string, unknown>[]>("collectionList", { collection: def.slug })
197
+ .then((r) => { if (live) setRows(r); })
198
+ .catch((e) => onError(errMsg(e)))
199
+ .finally(() => { if (live) setLoading(false); });
200
+ return () => { live = false; };
201
+ }, [api, def.slug, onError]);
202
+
203
+ const labelOf = (col: string) => def.fields.find((f) => f.name === col)?.label ?? col;
204
+ return (
205
+ <>
206
+ <Hero lead={def.pluralLabel} em={rows.length === 0 ? "None yet" : rows.length === 1 ? `1 ${def.label.toLowerCase()}` : `${rows.length} ${def.pluralLabel.toLowerCase()}`}>
207
+ <Cta text="Let's" em={`add a ${def.label.toLowerCase()}`}>
208
+ <Button className="shrink-0" onPress={onNew}>+ New {def.label.toLowerCase()}</Button>
209
+ </Cta>
210
+ </Hero>
211
+ <div className={WRAP}>
212
+ <div className="flex flex-col gap-2">
213
+ {rows.map((row) => {
214
+ const id = String(row[def.idField] ?? "");
215
+ const [first, ...rest] = def.list;
216
+ return (
217
+ <div className={`${ROW} cursor-pointer hover:bg-surface-muted`} key={id} onClick={() => onOpen(id)}>
218
+ <span className="flex-1 truncate font-medium">{cellText(row[first ?? def.titleField]) || <Dim>untitled</Dim>}</span>
219
+ {rest.map((col) => (
220
+ <span className="truncate text-fg-subtle" key={col} title={labelOf(col)}>{cellText(row[col])}</span>
221
+ ))}
222
+ </div>
223
+ );
224
+ })}
225
+ {!loading && rows.length === 0 ? <p className="text-fg-subtle">No {def.pluralLabel.toLowerCase()} yet. Create one.</p> : null}
226
+ {loading ? <p className="text-fg-subtle">Loading…</p> : null}
227
+ </div>
228
+ </div>
229
+ </>
230
+ );
231
+ }
232
+
233
+ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onError }: { api: Api; def: CollectionMeta; id: string | null; onSaved: () => void; onDeleted: () => void; onBack: () => void; onError: (s: string) => void }) {
234
+ const isNew = id === null;
235
+ const [values, setValues] = useState<Record<string, unknown>>({});
236
+ const [loading, setLoading] = useState(!isNew);
237
+ const [missing, setMissing] = useState(false);
238
+ const [busy, setBusy] = useState(false);
239
+ const [ok, setOk] = useState(false);
240
+
241
+ useEffect(() => {
242
+ if (isNew) { setValues({}); return; }
243
+ let live = true;
244
+ setLoading(true);
245
+ setMissing(false);
246
+ api.call<Record<string, unknown> | null>("collectionGet", { collection: def.slug, id })
247
+ .then((row) => { if (!live) return; if (row) setValues(row); else setMissing(true); })
248
+ .catch((e) => onError(errMsg(e)))
249
+ .finally(() => { if (live) setLoading(false); });
250
+ return () => { live = false; };
251
+ }, [api, def.slug, id, isNew, onError]);
252
+
253
+ const save = async () => {
254
+ setBusy(true);
255
+ try {
256
+ if (isNew) {
257
+ // Creating: a new record has no route yet — return to the list so the caller lands
258
+ // somewhere stable (the new row is now in it).
259
+ await api.call("collectionCreate", { collection: def.slug, values });
260
+ onSaved();
261
+ } else {
262
+ // Editing: stay on the form. Reflect the persisted row the update echoes back (server
263
+ // defaults / normalization applied) and flash a confirmation instead of navigating.
264
+ const updated = await api.call<Record<string, unknown>>("collectionUpdate", { collection: def.slug, id, values });
265
+ if (updated) setValues(updated);
266
+ setOk(true);
267
+ setTimeout(() => setOk(false), 1200);
268
+ }
269
+ } catch (e) {
270
+ onError(errMsg(e));
271
+ } finally {
272
+ setBusy(false);
273
+ }
274
+ };
275
+ const del = async () => {
276
+ if (isNew || !confirm(`Delete this ${def.label.toLowerCase()}? This cannot be undone.`)) return;
277
+ setBusy(true);
278
+ try {
279
+ await api.call("collectionDelete", { collection: def.slug, id });
280
+ onDeleted();
281
+ } catch (e) {
282
+ onError(errMsg(e));
283
+ } finally {
284
+ setBusy(false);
285
+ }
286
+ };
287
+
288
+ return (
289
+ <div className={WRAP}>
290
+ <div className="mb-4 mt-2 flex items-center gap-3">
291
+ <Button variant="ghost" size="sm" onPress={onBack}>← {def.pluralLabel}</Button>
292
+ <h1 className="text-[22px] font-normal text-fg">{isNew ? `New ${def.label.toLowerCase()}` : `Edit ${def.label.toLowerCase()}`}</h1>
293
+ </div>
294
+ {missing ? (
295
+ <p className="text-fg-subtle">Not found.</p>
296
+ ) : loading ? (
297
+ <p className="text-fg-subtle">Loading…</p>
298
+ ) : (
299
+ <div className="flex max-w-[720px] flex-col gap-4">
300
+ {ok ? <Banner ok>saved</Banner> : null}
301
+ <FieldForm schema={def.fields} value={values} onChange={setValues} api={api} />
302
+ <div className="mt-2 flex items-center gap-2">
303
+ <Button onPress={save} isDisabled={busy}>{busy ? "Saving…" : isNew ? "Create" : "Save"}</Button>
304
+ {!isNew ? <Button variant="ghost" className="text-danger" onPress={del} isDisabled={busy}>Delete</Button> : null}
305
+ </div>
306
+ </div>
307
+ )}
308
+ </div>
309
+ );
310
+ }
311
+
174
312
  // --- page editor -------------------------------------------------------------
175
313
 
176
314
  export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void }) {
@@ -351,7 +489,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
351
489
  <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
352
490
  ))}
353
491
  </div>
354
- {tab === "settings" ? <Settings page={page} /> : null}
492
+ {tab === "settings" ? <Settings api={api} page={page} ct={ct} initialFields={(assembled?.page.fields as Record<string, unknown>) ?? {}} onSaved={onChange} onError={setErr} /> : null}
355
493
  {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
356
494
  {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
357
495
  {tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
@@ -519,20 +657,50 @@ function AddBlock({ allowed, btBySlug, onAdd }: { allowed: string[]; btBySlug: M
519
657
  );
520
658
  }
521
659
 
522
- function Settings({ page }: { page: Page }) {
523
- // The core CMS API doesn't expose a general page-rename handler; keep this read-only +
524
- // link the essentials. (A future `updatePage` handler would back editable title/slug.)
660
+ // Edit the page record itself title/slug/locale and, for a content type with page-level
661
+ // fields (e.g. a "Lecture" with date/speaker), its structured `fields`. Backed by updatePage.
662
+ function Settings({ api, page, ct, initialFields, onSaved, onError }: { api: Api; page: Page; ct: ContentType | null; initialFields: Record<string, unknown>; onSaved: (p: Page) => void; onError: (s: string) => void }) {
663
+ const [title, setTitle] = useState(page.title);
664
+ const [slug, setSlug] = useState(page.slug);
665
+ const [locale, setLocale] = useState(page.locale);
666
+ const [fields, setFields] = useState<Record<string, unknown>>(initialFields);
667
+ const [ok, setOk] = useState(false);
668
+ const [busy, setBusy] = useState(false);
669
+ const schema: FieldDefinition[] = ct?.fieldsSchema ?? [];
670
+
671
+ useEffect(() => { setTitle(page.title); setSlug(page.slug); setLocale(page.locale); }, [page.id, page.title, page.slug, page.locale]);
672
+ useEffect(() => { setFields(initialFields); }, [initialFields]);
673
+
674
+ const save = async () => {
675
+ setBusy(true);
676
+ try {
677
+ const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, title, slug, locale, ...(schema.length ? { fields } : {}) });
678
+ if (r?.page) onSaved(r.page);
679
+ setOk(true);
680
+ setTimeout(() => setOk(false), 1500);
681
+ } catch (e) {
682
+ onError(errMsg(e));
683
+ } finally {
684
+ setBusy(false);
685
+ }
686
+ };
687
+
525
688
  return (
526
- <KV>
527
- <span>Title</span>
528
- <span>{page.title}</span>
529
- <span>Slug</span>
530
- <span>/{page.slug}</span>
531
- <span>Locale</span>
532
- <span>{page.locale}</span>
533
- <span>Status</span>
534
- <span>{page.status}</span>
535
- </KV>
689
+ <div className="flex flex-col gap-4">
690
+ <Section>Page</Section>
691
+ {ok ? <Banner ok>saved</Banner> : null}
692
+ <Input label="Title" value={title} onChange={setTitle} />
693
+ <Input label="Slug" value={slug} onChange={setSlug} />
694
+ <Input label="Locale" value={locale} onChange={setLocale} />
695
+ {schema.length ? (
696
+ <>
697
+ <Section>Fields</Section>
698
+ <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />
699
+ </>
700
+ ) : null}
701
+ <Button onPress={save} isDisabled={busy || !title.trim() || !slug.trim()}>{busy ? "Saving…" : "Save"}</Button>
702
+ <KV><span>Status</span><span>{page.status}</span></KV>
703
+ </div>
536
704
  );
537
705
  }
538
706
 
@@ -7,12 +7,16 @@ import { Button } from "@podoba/react";
7
7
  import { useApp } from "../app-context";
8
8
 
9
9
  export default function RootLayout() {
10
- const { cfg, isAdmin, error, reconfigure } = useApp();
10
+ const { cfg, isAdmin, collections, error, reconfigure } = useApp();
11
11
  const navigate = useNavigate();
12
12
  const { pathname } = useRoute();
13
13
 
14
+ // The active collection slug, if we're under /collections/:slug(/...).
15
+ const collectionSlug = pathname.startsWith("/collections/") ? pathname.split("/")[2] : undefined;
16
+
14
17
  // "Pages" stays lit while editing a page (/pages/:id) too.
15
- const active = pathname.startsWith("/pages") || pathname === "/" ? "pages"
18
+ const active = collectionSlug ? `col:${collectionSlug}`
19
+ : pathname.startsWith("/pages") || pathname === "/" ? "pages"
16
20
  : pathname.startsWith("/media") ? "media"
17
21
  : pathname.startsWith("/users") ? "users"
18
22
  : pathname.startsWith("/settings") ? "settings"
@@ -33,6 +37,11 @@ export default function RootLayout() {
33
37
  <span className="flex-1" />
34
38
  <nav className="flex items-center gap-0.5">
35
39
  <Button variant="ghost" size="sm" className={tabCls("pages")} onPress={() => navigate("home")}>Pages</Button>
40
+ {collections.map((c) => (
41
+ <Button key={c.slug} variant="ghost" size="sm" className={tabCls(`col:${c.slug}`)} onPress={() => navigate("collection", { params: { slug: c.slug } })}>
42
+ {c.icon ? `${c.icon} ` : ""}{c.pluralLabel}
43
+ </Button>
44
+ ))}
36
45
  <Button variant="ghost" size="sm" className={tabCls("media")} onPress={() => navigate("media")}>Media</Button>
37
46
  {isAdmin ? (
38
47
  <Button variant="ghost" size="sm" className={tabCls("users")} onPress={() => navigate("users")}>Users</Button>
@@ -0,0 +1,38 @@
1
+ // Collection item route (`/collections/:slug/:id`). `:id` == "new" is the create form;
2
+ // any other value edits that row. Both render through the generic CollectionEditor
3
+ // (FieldForm over the collection's field schema). All navigation returns to the list.
4
+
5
+ import { createPage, useNavigate } from "@buzola/router";
6
+ import { Button } from "@podoba/react";
7
+ import { useApp } from "../app-context";
8
+ import { CollectionEditor } from "../components";
9
+
10
+ export default createPage()
11
+ .params({ slug: "string", id: "string" })
12
+ .route("/collections/:slug/:id")
13
+ .render(function CollectionItemRoute({ params }) {
14
+ const { api, collections, setError } = useApp();
15
+ const navigate = useNavigate();
16
+ const def = collections.find((c) => c.slug === params.slug);
17
+ const backToList = () => navigate("collection", { params: { slug: params.slug } });
18
+
19
+ if (!def) {
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>
25
+ );
26
+ }
27
+ return (
28
+ <CollectionEditor
29
+ api={api}
30
+ def={def}
31
+ id={params.id === "new" ? null : params.id}
32
+ onSaved={backToList}
33
+ onDeleted={backToList}
34
+ onBack={backToList}
35
+ onError={setError}
36
+ />
37
+ );
38
+ });
@@ -0,0 +1,36 @@
1
+ // Collection list route (`/collections/:slug`). Generic over any registered collection —
2
+ // resolves the CollectionMeta from the app context and renders its rows. Opening a row (or
3
+ // "+ New") navigates to the item route.
4
+
5
+ import { createPage, useNavigate } from "@buzola/router";
6
+ import { Button } from "@podoba/react";
7
+ import { useApp } from "../app-context";
8
+ import { CollectionList } from "../components";
9
+
10
+ export default createPage()
11
+ .params({ slug: "string" })
12
+ .route("/collections/:slug")
13
+ .render(function CollectionListRoute({ params }) {
14
+ const { api, collections, setError } = useApp();
15
+ const navigate = useNavigate();
16
+ const def = collections.find((c) => c.slug === params.slug);
17
+
18
+ if (!def) {
19
+ // Collections load async; before they arrive (or for a bad slug) show a neutral state.
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>
25
+ );
26
+ }
27
+ return (
28
+ <CollectionList
29
+ api={api}
30
+ def={def}
31
+ onOpen={(id) => navigate("collection-item", { params: { slug: def.slug, id } })}
32
+ onNew={() => navigate("collection-item", { params: { slug: def.slug, id: "new" } })}
33
+ onError={setError}
34
+ />
35
+ );
36
+ });
package/src/types.ts CHANGED
@@ -63,6 +63,24 @@ export interface ContentType {
63
63
  defaultBlocks?: DefaultBlockDefinition[] | null;
64
64
  }
65
65
 
66
+ /** A collection: one of the host app's own pramen entities, edited generically via a
67
+ * field schema (mirror of @pramen/cms `CollectionMeta`). Fetched from `listCollections`
68
+ * and used to build the nav + the generic list/edit views. No `entity`/`idField` — those
69
+ * are server-only; the editor addresses a collection purely by `slug`. */
70
+ export interface CollectionMeta {
71
+ slug: string;
72
+ label: string;
73
+ pluralLabel: string;
74
+ icon?: string;
75
+ fields: FieldDefinition[];
76
+ list: string[];
77
+ titleField: string;
78
+ /** The entity's PK column name (defaults "id" server-side) — the editor reads a row's id
79
+ * from this to open/save/delete it. */
80
+ idField: string;
81
+ orderBy?: { column: string; dir?: "asc" | "desc" };
82
+ }
83
+
66
84
  export interface Page {
67
85
  id: string;
68
86
  typeId: string;
@@ -70,6 +88,7 @@ export interface Page {
70
88
  slug: string;
71
89
  status: string;
72
90
  locale: string;
91
+ fields?: Record<string, unknown> | null; // content-type-level structured data (fieldsSchema)
73
92
  translationGroupId?: string | null;
74
93
  metaTitle?: string | null;
75
94
  metaDescription?: string | null;