@pramen/cms-editor 0.0.63 → 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.
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
@@ -431,15 +431,19 @@ export interface WidgetArea {
431
431
 
432
432
  // --- Block Kit: custom admin pages (mirrors @pramen/cms `./blockkit`) ----------------
433
433
 
434
- /** 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. */
435
439
  export type AdminInput =
436
- | { type: "text_input"; action_id: string; label?: string; placeholder?: string; initial_value?: string; multiline?: boolean; required?: boolean }
437
- | { type: "number_input"; action_id: string; label?: string; placeholder?: string; initial_value?: number; min?: number; max?: number; required?: boolean }
438
- | { type: "select"; action_id: string; label?: string; options: { value: string; label: string }[]; initial_value?: string; required?: boolean }
439
- | { 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 }
440
444
  /** Write-only: deliberately has NO `initial_value`, so a stored secret is never echoed
441
445
  * back into the admin's DOM. */
442
- | { 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 };
443
447
 
444
448
  export interface AdminButton {
445
449
  type: "button";
@@ -453,13 +457,24 @@ export interface AdminButton {
453
457
 
454
458
  export type AdminElement = AdminButton | AdminInput;
455
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
+
456
471
  export type AdminBlock =
457
472
  | { type: "header"; text: string; level?: 1 | 2 | 3 }
458
473
  | { type: "section"; text: string }
459
474
  | { type: "divider" }
460
475
  | { type: "context"; text: string }
461
476
  | { type: "fields"; fields: { label: string; value: string }[] }
462
- | { 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 }
463
478
  | { type: "stats"; stats: { label: string; value: string; hint?: string }[] }
464
479
  | { type: "actions"; block_id?: string; elements: AdminElement[] }
465
480
  | { type: "form"; block_id: string; fields: AdminInput[]; submit: { label: string; action_id: string } }
@@ -474,6 +489,15 @@ export interface AdminPageResponse {
474
489
  toast?: { text: string; tone?: "info" | "success" | "error" };
475
490
  }
476
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
+
477
501
  /** A custom admin page, as the editor sees it (from `listAdminPages`) — never the render
478
502
  * function, and never the role list. A page the caller may not open is simply absent. */
479
503
  export interface AdminPageMeta {
@@ -481,6 +505,16 @@ export interface AdminPageMeta {
481
505
  label: string;
482
506
  icon?: string;
483
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;
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";
484
518
  }
485
519
 
486
520
  // --- media sorting and filtering -----------------------------------------------------------