@pramen/cms-editor 0.0.63 → 0.0.65
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/README.md +52 -1
- package/dist/editor.js +117 -117
- package/dist/panel-jsx-dev-runtime.js +8 -0
- package/dist/panel-jsx-runtime.js +8 -0
- package/dist/panel-react-dom.js +8 -0
- package/dist/panel-react.js +8 -0
- package/package.json +6 -2
- package/src/app-context.tsx +9 -3
- package/src/blockkit.tsx +109 -32
- package/src/components.tsx +21 -4
- package/src/main.tsx +18 -0
- package/src/panel-boundary.tsx +67 -0
- package/src/panel-globals.ts +103 -0
- package/src/panel-runtime.ts +105 -0
- package/src/panels.ts +420 -0
- package/src/routes/_layout.tsx +5 -7
- package/src/routes/admin-page.tsx +77 -3
- package/src/theme.ts +80 -0
- package/src/types.ts +41 -7
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
|
|
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,
|
|
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 -----------------------------------------------------------
|