@pramen/cms-editor 0.0.61 → 0.0.64

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.
@@ -1,14 +1,26 @@
1
- // A custom admin page (`/apps/:slug`) — Block Kit, rendered inside the editor's own chrome.
1
+ // A project's own screen (`/apps/:slug`), rendered inside the editor's own chrome.
2
2
  //
3
3
  // Routed BY THE EDITOR, which is the point: an `extraNav` link has to open a new tab,
4
4
  // because `_404.tsx` registers the catch-all `/:__notFound+` and a same-tab click on an
5
- // unmounted editor lands on the in-app 404. A registered page has a real route, so it is
5
+ // unmounted editor lands on the in-app 404. A registered screen has a real route, so it is
6
6
  // part of the admin rather than a link out of it.
7
+ //
8
+ // ONE route for two kinds. A Block Kit page (`adminPage()`) is described as JSON by the
9
+ // server and rendered here; a panel (`adminPanel()`) is a React component the deployment's
10
+ // own bundle registered. They share this route because they share everything a URL and a nav
11
+ // entry are made of — the slug space, the role filter, the "Apps" band, the breadcrumb — and
12
+ // differ only in where the rendering happens. Splitting them would have meant a second
13
+ // route, a second nav band and a slug that could mean two things.
7
14
 
8
- import { createPage, useNavigate } from "@buzola/router";
15
+ import { createPage, useNavigate, useRouter } from "@buzola/router";
16
+ import { useMemo, useSyncExternalStore } from "react";
9
17
  import { useApp } from "../app-context";
10
18
  import { AdminPageView } from "../blockkit";
11
19
  import { Notice } from "../components";
20
+ import { PanelBoundary } from "../panel-boundary";
21
+ import { getPanel, panelRefusal, panelsSettled, panelsVersion, subscribePanels, type PanelProps } from "../panels";
22
+ import { useTheme } from "../theme";
23
+ import { adminPageKind } from "../types";
12
24
  import { Button } from "@podoba/react";
13
25
 
14
26
  export default createPage()
@@ -29,8 +41,70 @@ export default createPage()
29
41
  </Notice>
30
42
  );
31
43
  }
44
+ if (adminPageKind(def) === "panel") return <PanelRoute slug={def.slug} />;
32
45
  // Keyed on the slug so switching between two pages REMOUNTS the view: buzola renders the
33
46
  // same component instance across a params-only change, and the blocks, the form values
34
47
  // and any toast all belong to one page.
35
48
  return <AdminPageView api={api} key={def.slug} slug={def.slug} label={def.label} onError={setError} />;
36
49
  });
50
+
51
+ /**
52
+ * A panel: the component the deployment's own bundle registered for this slug.
53
+ *
54
+ * The registry is external state that changes without React knowing — bundles are imported
55
+ * from `main.tsx` and register whenever they land — so it is read through
56
+ * `useSyncExternalStore` rather than an effect. That is what makes a deep link to a panel
57
+ * work: the route can mount before the bundle has finished loading, and re-renders when it
58
+ * has, instead of deciding once and being wrong forever.
59
+ */
60
+ function PanelRoute({ slug }: { slug: string }) {
61
+ const { api, setError } = useApp();
62
+ const theme = useTheme();
63
+ const basePath = useRouter().basePath;
64
+ const version = useSyncExternalStore(subscribePanels, panelsVersion, panelsVersion);
65
+ // `version` is the snapshot, not the value — a stable number is what a store hook needs,
66
+ // and the lookup is what the render actually wants. Depending on it is the point.
67
+ const panel = useMemo(() => getPanel(slug), [slug, version]);
68
+
69
+ if (!panel) {
70
+ // Three different situations, and only the first is a spinner.
71
+ //
72
+ // A REFUSAL comes first because it is the one the generic message would actively mislead
73
+ // about: the bundle is listed, it loaded, it ran, and it called `registerPanel` — telling
74
+ // the reader to go and check those four things sends them past the actual answer. The
75
+ // registry already holds a sentence naming the slug and the fix (a contract built against
76
+ // a different editor, a `render` that is not a component), so it is shown verbatim.
77
+ //
78
+ // Otherwise: the server listed this panel (it is in `adminPages`, so the caller may open
79
+ // it), which means the bundle either has not landed yet or landed and never registered
80
+ // this slug. The last is a deployment mistake — a missing `panels` entry, a bundle built
81
+ // without the `registerPanel` call, a slug typo between `app.ts` and the bundle — and it
82
+ // is one nobody can diagnose from a spinner.
83
+ //
84
+ // Read during render, not through a second store: a refusal calls the same `notify` a
85
+ // registration does, so `version` above is already the subscription that brings this
86
+ // component back when one is recorded.
87
+ const refused = panelRefusal(slug);
88
+ return (
89
+ <Notice>
90
+ {refused ??
91
+ (panelsSettled()
92
+ ? `No panel is registered for '${slug}'. Check that this deployment's panel bundle is listed in the admin's \`panels\` config and calls registerPanel({ slug: "${slug}", … }).`
93
+ : "Loading…")}
94
+ </Notice>
95
+ );
96
+ }
97
+
98
+ const props: PanelProps = { api, basePath, theme, setError };
99
+ // Keyed on the slug for the same reason a Block Kit page is: buzola keeps one component
100
+ // instance across a params-only change, and a panel's state belongs to its own screen.
101
+ //
102
+ // Wrapped, because this is someone else's component in our tree: React unmounts the whole
103
+ // root on an uncaught render error, so without the boundary one bad panel does not break a
104
+ // screen, it blanks the admin — no sidebar, and no way off the route that is failing.
105
+ return (
106
+ <PanelBoundary key={slug} slug={slug}>
107
+ <panel.render {...props} />
108
+ </PanelBoundary>
109
+ );
110
+ }
@@ -73,6 +73,10 @@ export default createPage()
73
73
  tab={tab}
74
74
  onTab={setTab}
75
75
  onBack={backToList}
76
+ // Named for where it actually goes. On a per-type deployment `backToList` lands in
77
+ // this page's OWN type list, and a button labelled "Pages" then named a pooled list
78
+ // that deployment does not have.
79
+ backLabel={splitsByType(contentTypes, cms) && ownType ? ownType.name : "Pages"}
76
80
  onChange={setPage}
77
81
  registerGuard={setNavGuard}
78
82
  />
package/src/theme.ts ADDED
@@ -0,0 +1,80 @@
1
+ // The chrome's light/dark choice, as a store rather than a component's state.
2
+ //
3
+ // It used to be a `useState` inside `_layout.tsx`, which was fine while the layout was the
4
+ // only reader. It is not the only reader any more: a panel (a project's own React screen —
5
+ // see `panels.ts`) is handed the theme, and it is rendered through `<Outlet />` under that
6
+ // same layout. Threading it down as a prop would mean every route the layout renders had to
7
+ // carry a value only one of them wants; a second `useState` would mean two sources of truth
8
+ // for one document attribute, drifting the moment either is set.
9
+ //
10
+ // A store instead: one value, one place that writes the DOM attribute and localStorage, and
11
+ // a `useSyncExternalStore` hook for anyone who wants to re-render on a change. It is also
12
+ // what lets `main.tsx` apply the stored theme BEFORE the first paint, which the layout's
13
+ // effect could not do — an editor left in dark mode used to flash white on every load.
14
+
15
+ import { useSyncExternalStore } from "react";
16
+
17
+ export type Theme = "light" | "dark";
18
+
19
+ /** Per-browser, like the folded nav groups: a reading preference, not deployment
20
+ * configuration, so nothing server-side carries it. */
21
+ const THEME_KEY = "pramen.cms.theme";
22
+
23
+ let current: Theme = "light";
24
+ const listeners = new Set<() => void>();
25
+
26
+ /** Read the stored choice, tolerating every shape localStorage can be in (absent, another
27
+ * version's value, hand-edited, a private window that throws on access). Anything that is
28
+ * not exactly `"dark"` is light, which is the default the editor has always had. */
29
+ function stored(): Theme {
30
+ try {
31
+ return localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light";
32
+ } catch {
33
+ return "light";
34
+ }
35
+ }
36
+
37
+ /** Put the choice where podoba can see it. The tokens flip under `[data-theme="dark"]` on
38
+ * the document root — there are no `dark:` variants to toggle — so this one attribute is the
39
+ * whole of "apply the theme". */
40
+ function apply(theme: Theme): void {
41
+ if (typeof document !== "undefined") document.documentElement.dataset.theme = theme;
42
+ }
43
+
44
+ /** Adopt the stored choice and paint it. Called once, from `main.tsx`, before `createRoot`. */
45
+ export function initTheme(): void {
46
+ current = stored();
47
+ apply(current);
48
+ }
49
+
50
+ export function getTheme(): Theme {
51
+ return current;
52
+ }
53
+
54
+ export function setTheme(theme: Theme): void {
55
+ if (theme === current) return;
56
+ current = theme;
57
+ apply(theme);
58
+ try {
59
+ localStorage.setItem(THEME_KEY, theme);
60
+ } catch {
61
+ // A private window that refuses writes costs the memory of the choice, nothing else.
62
+ }
63
+ for (const listener of [...listeners]) listener();
64
+ }
65
+
66
+ export function subscribeTheme(listener: () => void): () => void {
67
+ listeners.add(listener);
68
+ return () => listeners.delete(listener);
69
+ }
70
+
71
+ /** The current theme, re-rendering the caller when it changes. */
72
+ export function useTheme(): Theme {
73
+ return useSyncExternalStore(subscribeTheme, getTheme, getTheme);
74
+ }
75
+
76
+ /** Drop every listener and return to the default. Tests only — the store is module state. */
77
+ export function resetTheme(): void {
78
+ listeners.clear();
79
+ current = "light";
80
+ }
package/src/types.ts CHANGED
@@ -209,6 +209,11 @@ export interface CmsCapabilities {
209
209
  * configured. Without it a reviewer-only session gets the authoring nav and every screen
210
210
  * 403s on its first save. */
211
211
  canEdit: boolean;
212
+ /** The server has `listMediaTerms`/`setMediaTerms`, and `listMedia` understands `term`.
213
+ * Declared like `siteFurniture`: on an older server the detail panel's Tags section would
214
+ * 404 the moment a file is opened, and the library's tag filter would send an argument
215
+ * that is silently ignored — a control that visibly does nothing. */
216
+ mediaTerms: boolean;
212
217
  }
213
218
 
214
219
  /**
@@ -244,6 +249,7 @@ export const DEFAULT_CAPABILITIES: CmsCapabilities = {
244
249
  pagesByType: false,
245
250
  siteFurniture: false,
246
251
  codeDefinedTypes: false,
252
+ mediaTerms: false,
247
253
  // Fails OPEN, unlike its neighbours. An older server sends no `canEdit`, and hiding
248
254
  // every authoring control from a real editor is unrecoverable from inside the editor;
249
255
  // showing one that 403s is a legible error with a way forward. The server is the
@@ -371,8 +377,25 @@ export interface Taxonomy {
371
377
  pluralLabel?: string | null;
372
378
  description?: string | null;
373
379
  hierarchical?: boolean;
380
+ /** What this vocabulary classifies. `null`/absent means EVERYTHING — a vocabulary that was
381
+ * never narrowed, and the reading for a row written before the column existed. */
382
+ appliesTo?: TaxonomyTarget[] | null;
374
383
  }
375
384
 
385
+ /** Mirror of @pramen/cms `TAXONOMY_TARGETS` — the object types a vocabulary can classify.
386
+ * Mirrored rather than imported for the reason at the top of this file: the editor is a
387
+ * standalone browser app with no server-package dependency. The server is the authority; an
388
+ * unknown value here would just render an unchecked box it refuses to save. */
389
+ export const TAXONOMY_TARGETS = ["page", "media"] as const;
390
+ export type TaxonomyTarget = (typeof TAXONOMY_TARGETS)[number];
391
+
392
+ /** What each target is called on screen — plural, because each names the SET of things the
393
+ * vocabulary would classify. */
394
+ export const TAXONOMY_TARGET_LABELS = {
395
+ page: "Pages",
396
+ media: "Media",
397
+ } satisfies Record<TaxonomyTarget, string>;
398
+
376
399
  export interface Term {
377
400
  id: string;
378
401
  taxonomyId: string;
@@ -408,15 +431,19 @@ export interface WidgetArea {
408
431
 
409
432
  // --- Block Kit: custom admin pages (mirrors @pramen/cms `./blockkit`) ----------------
410
433
 
411
- /** An input a Block Kit form or actions row can carry. */
434
+ /** An input a Block Kit form, actions row or table cell can carry.
435
+ *
436
+ * `error` is the per-FIELD failure, drawn under the input it belongs to. It is part of the
437
+ * render, not client state: the whole page comes back on every interaction, so an error
438
+ * lasts exactly as long as the response that carried it. */
412
439
  export type AdminInput =
413
- | { type: "text_input"; action_id: string; label?: string; placeholder?: string; initial_value?: string; multiline?: boolean; required?: boolean }
414
- | { type: "number_input"; action_id: string; label?: string; placeholder?: string; initial_value?: number; min?: number; max?: number; required?: boolean }
415
- | { type: "select"; action_id: string; label?: string; options: { value: string; label: string }[]; initial_value?: string; required?: boolean }
416
- | { type: "toggle"; action_id: string; label?: string; initial_value?: boolean }
440
+ | { type: "text_input"; action_id: string; label?: string; placeholder?: string; initial_value?: string; multiline?: boolean; required?: boolean; error?: string }
441
+ | { type: "number_input"; action_id: string; label?: string; placeholder?: string; initial_value?: number; min?: number; max?: number; required?: boolean; error?: string }
442
+ | { type: "select"; action_id: string; label?: string; options: { value: string; label: string }[]; initial_value?: string; required?: boolean; error?: string }
443
+ | { type: "toggle"; action_id: string; label?: string; initial_value?: boolean; error?: string }
417
444
  /** Write-only: deliberately has NO `initial_value`, so a stored secret is never echoed
418
445
  * back into the admin's DOM. */
419
- | { type: "secret_input"; action_id: string; label?: string; placeholder?: string; required?: boolean };
446
+ | { type: "secret_input"; action_id: string; label?: string; placeholder?: string; required?: boolean; error?: string };
420
447
 
421
448
  export interface AdminButton {
422
449
  type: "button";
@@ -430,13 +457,24 @@ export interface AdminButton {
430
457
 
431
458
  export type AdminElement = AdminButton | AdminInput;
432
459
 
460
+ /** Every `AdminElement` tag, as a runtime set — the editor needs it to decide whether a
461
+ * table cell draws as text or as a control. Mirrors `@pramen/cms`; the two are asserted
462
+ * equal in `test/cms-editor-mirrors.test.ts`, because a tag missing here renders a live
463
+ * control as `[object Object]`. */
464
+ export const ADMIN_ELEMENT_TYPES = ["button", "text_input", "number_input", "select", "toggle", "secret_input"] as const;
465
+
466
+ /** What one table cell holds: a value to READ, or an element to ACT with. Told apart by
467
+ * shape — a display value is a primitive, an element is an object. The server refuses any
468
+ * other object on the way out. */
469
+ export type AdminCell = string | number | boolean | null | AdminElement;
470
+
433
471
  export type AdminBlock =
434
472
  | { type: "header"; text: string; level?: 1 | 2 | 3 }
435
473
  | { type: "section"; text: string }
436
474
  | { type: "divider" }
437
475
  | { type: "context"; text: string }
438
476
  | { type: "fields"; fields: { label: string; value: string }[] }
439
- | { type: "table"; columns: { key: string; label: string }[]; rows: Record<string, string | number | boolean | null>[]; empty?: string }
477
+ | { type: "table"; block_id?: string; columns: { key: string; label: string }[]; rows: Record<string, AdminCell>[]; empty?: string }
440
478
  | { type: "stats"; stats: { label: string; value: string; hint?: string }[] }
441
479
  | { type: "actions"; block_id?: string; elements: AdminElement[] }
442
480
  | { type: "form"; block_id: string; fields: AdminInput[]; submit: { label: string; action_id: string } }
@@ -451,6 +489,15 @@ export interface AdminPageResponse {
451
489
  toast?: { text: string; tone?: "info" | "success" | "error" };
452
490
  }
453
491
 
492
+ /** How a registered admin screen draws. Mirror of `ADMIN_PAGE_KINDS` in @pramen/cms.
493
+ *
494
+ * `"blocks"` is Block Kit — the server describes the page as JSON and the editor renders it.
495
+ * `"panel"` is a React component the deployment's own panel bundle registered under the
496
+ * same slug (see `panels.ts`); the server still owns the entry, so the role filter, the
497
+ * label, the icon and the position are the same server facts for both. */
498
+ export const ADMIN_PAGE_KINDS = ["blocks", "panel"] as const;
499
+ export type AdminPageKind = (typeof ADMIN_PAGE_KINDS)[number];
500
+
454
501
  /** A custom admin page, as the editor sees it (from `listAdminPages`) — never the render
455
502
  * function, and never the role list. A page the caller may not open is simply absent. */
456
503
  export interface AdminPageMeta {
@@ -458,4 +505,53 @@ export interface AdminPageMeta {
458
505
  label: string;
459
506
  icon?: string;
460
507
  navOrder?: number;
508
+ /** Absent ⇒ `"blocks"`, which is what every entry was before panels existed and what an
509
+ * older server still sends. Read through {@link adminPageKind} rather than directly, so
510
+ * the default lives in one place and an unrecognised kind from a NEWER server degrades to
511
+ * a Block Kit page (which renders a legible server error) instead of a blank screen. */
512
+ kind?: string;
461
513
  }
514
+
515
+ /** The kind an entry actually is, defaulted and validated. */
516
+ export function adminPageKind(meta: AdminPageMeta): AdminPageKind {
517
+ return (ADMIN_PAGE_KINDS as readonly string[]).includes(meta.kind ?? "") ? (meta.kind as AdminPageKind) : "blocks";
518
+ }
519
+
520
+ // --- media sorting and filtering -----------------------------------------------------------
521
+ //
522
+ // Mirrors of the vocabularies `listMedia` accepts in @pramen/cms. Mirrored rather than
523
+ // imported for the reason at the top of this file — the editor is a standalone browser app
524
+ // with no server-package dependency — and checked against the originals in
525
+ // `test/cms-editor-mirrors.test.ts`, because a duplicate nobody verifies is a latent bug with
526
+ // a comment on it.
527
+ //
528
+ // The server treats an unrecognised value as absent, so a drift here degrades to the default
529
+ // order rather than to an error. That is the failure worth having, and it is still a failure:
530
+ // a sort the editor offers and the server drops is a control that silently does nothing.
531
+
532
+ /** How the media library may be ordered. */
533
+ export const MEDIA_SORTS = ["newest", "oldest", "name", "name_desc", "largest", "smallest"] as const;
534
+ export type MediaSort = (typeof MEDIA_SORTS)[number];
535
+
536
+ /** What each sort is called on screen. */
537
+ export const MEDIA_SORT_LABELS = {
538
+ newest: "Newest first",
539
+ oldest: "Oldest first",
540
+ name: "Name A–Z",
541
+ name_desc: "Name Z–A",
542
+ largest: "Largest first",
543
+ smallest: "Smallest first",
544
+ } satisfies Record<MediaSort, string>;
545
+
546
+ /** The coarse type buckets the library filters by. */
547
+ export const MEDIA_KINDS = ["image", "video", "audio", "document", "other"] as const;
548
+ export type MediaKind = (typeof MEDIA_KINDS)[number];
549
+
550
+ /** …and their labels. Plural, because each names a SET the filter narrows to. */
551
+ export const MEDIA_KIND_LABELS = {
552
+ image: "Images",
553
+ video: "Video",
554
+ audio: "Audio",
555
+ document: "Documents",
556
+ other: "Other",
557
+ } satisfies Record<MediaKind, string>;