@pramen/cms-editor 0.0.48 → 0.0.49

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.48",
3
+ "version": "0.0.49",
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": {
package/src/api.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // same transport shape as @pramen/admin's api.ts. Config is persisted in localStorage.
3
3
 
4
4
  import type { AssembledPage, AuditEntry, BlockType, ContentType, Media, Page } from "./types";
5
+ import type { RpcInput } from "./types";
5
6
 
6
7
  export interface Config {
7
8
  baseUrl: string;
@@ -72,20 +73,21 @@ export class Api {
72
73
  }
73
74
 
74
75
  /** Call a CMS RPC handler. Throws ApiError on a non-`ok` envelope. */
75
- async call<T = unknown>(name: string, input?: unknown): Promise<T> {
76
+ async call<T = unknown>(name: string, input?: RpcInput): Promise<T> {
76
77
  // Expired token: hand off to sign-in instead of firing a request that will 403 into an
77
78
  // error banner. The returned promise never settles — navigation is already underway.
78
79
  if (this.onExpired && this.cfg.token && isTokenExpired(this.cfg.token)) {
79
80
  this.onExpired();
80
81
  return new Promise<T>(() => {});
81
82
  }
83
+ const headers = new Headers({
84
+ "content-type": "application/json",
85
+ "x-pramen-tenant": this.cfg.tenant || "main",
86
+ });
87
+ if (this.cfg.token) headers.set("authorization", `Bearer ${this.cfg.token}`);
82
88
  const res = await fetch(`${this.base()}/rpc/${name}`, {
83
89
  method: "POST",
84
- headers: {
85
- "content-type": "application/json",
86
- "x-pramen-tenant": this.cfg.tenant || "main",
87
- ...(this.cfg.token ? { authorization: `Bearer ${this.cfg.token}` } : {}),
88
- },
90
+ headers,
89
91
  body: JSON.stringify(input ?? {}),
90
92
  });
91
93
  let body: { ok?: boolean; result?: unknown; error?: string; code?: string };
@@ -6,7 +6,7 @@
6
6
  import { Button, Input } from "@podoba/react";
7
7
  import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
9
- import type { CollectionMeta } from "./types";
9
+ import type { CollectionMeta, JsonValue } from "./types";
10
10
 
11
11
  declare global {
12
12
  interface Window {
@@ -42,7 +42,7 @@ function redirectToSignIn(): void {
42
42
  export interface Me {
43
43
  userId?: string;
44
44
  roles?: string[];
45
- [k: string]: unknown;
45
+ [k: string]: JsonValue | undefined;
46
46
  }
47
47
 
48
48
  interface AppContextValue {
@@ -8,7 +8,7 @@ import { Api, ApiError } from "./api";
8
8
  import { FieldForm, slugify } from "./fields";
9
9
  import type { Config } from "./api";
10
10
  import type { Me } from "./app-context";
11
- import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, Media, Page, RegionDefinition, RenderedBlock } from "./types";
11
+ import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValues, 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"];
@@ -201,7 +201,7 @@ function cellText(v: unknown): string {
201
201
  const COLLECTION_PAGE_SIZE = 50;
202
202
 
203
203
  export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api; def: CollectionMeta; onOpen: (id: string) => void; onNew: () => void; onError: (s: string) => void }) {
204
- const [rows, setRows] = useState<Record<string, unknown>[]>([]);
204
+ const [rows, setRows] = useState<FieldValues[]>([]);
205
205
  const [offset, setOffset] = useState(0);
206
206
  const [hasMore, setHasMore] = useState(false);
207
207
  const [loading, setLoading] = useState(true);
@@ -210,7 +210,7 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
210
210
  (off: number) => {
211
211
  setLoading(true);
212
212
  return api
213
- .call<Record<string, unknown>[]>("collectionList", { collection: def.slug, limit: COLLECTION_PAGE_SIZE, offset: off })
213
+ .call<FieldValues[]>("collectionList", { collection: def.slug, limit: COLLECTION_PAGE_SIZE, offset: off })
214
214
  .then((r) => {
215
215
  setRows((prev) => (off === 0 ? r : [...prev, ...r]));
216
216
  // A full page means there is probably more; a short one is definitely the end.
@@ -267,7 +267,7 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
267
267
 
268
268
  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 }) {
269
269
  const isNew = id === null;
270
- const [values, setValues] = useState<Record<string, unknown>>({});
270
+ const [values, setValues] = useState<FieldValues>({});
271
271
  const [loading, setLoading] = useState(!isNew);
272
272
  const [missing, setMissing] = useState(false);
273
273
  const [busy, setBusy] = useState(false);
@@ -278,7 +278,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
278
278
  let live = true;
279
279
  setLoading(true);
280
280
  setMissing(false);
281
- api.call<Record<string, unknown> | null>("collectionGet", { collection: def.slug, id })
281
+ api.call<FieldValues | null>("collectionGet", { collection: def.slug, id })
282
282
  .then((row) => { if (!live) return; if (row) setValues(row); else setMissing(true); })
283
283
  .catch((e) => onError(errMsg(e)))
284
284
  .finally(() => { if (live) setLoading(false); });
@@ -296,7 +296,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
296
296
  } else {
297
297
  // Editing: stay on the form. Reflect the persisted row the update echoes back (server
298
298
  // defaults / normalization applied) and flash a confirmation instead of navigating.
299
- const updated = await api.call<Record<string, unknown>>("collectionUpdate", { collection: def.slug, id, values });
299
+ const updated = await api.call<FieldValues>("collectionUpdate", { collection: def.slug, id, values });
300
300
  if (updated) setValues(updated);
301
301
  setOk(true);
302
302
  setTimeout(() => setOk(false), 1200);
@@ -427,7 +427,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
427
427
  });
428
428
  try {
429
429
  const { block, placement } = await api.call<{
430
- block: { id: string; title?: string | null; fields?: Record<string, unknown> | null };
430
+ block: { id: string; title?: string | null; fields?: FieldValues | null };
431
431
  placement: { id: string; isShared?: boolean | number };
432
432
  }>("addBlock", { pageId: page.id, blockTypeSlug: slug, region, fields: {} });
433
433
  const rb: RenderedBlock = {
@@ -470,7 +470,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
470
470
 
471
471
  // Patch a block's raw fields into local state after an inline save — keeps the collapsed
472
472
  // preview fresh without a full reload (which would remount every editor + lose caret/focus).
473
- const patchBlockFields = useCallback((placementId: string, fields: Record<string, unknown>) => {
473
+ const patchBlockFields = useCallback((placementId: string, fields: FieldValues) => {
474
474
  setAssembled((prev) => {
475
475
  if (!prev) return prev;
476
476
  const next: Record<string, RenderedBlock[]> = {};
@@ -539,7 +539,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
539
539
  not in the inspector. For a content type with no regions (a fixed layout, all
540
540
  of it page fields) this is the entire editor; the canvas is never empty. */}
541
541
  {pageSchema.length ? (
542
- <PageFields api={api} page={page} schema={pageSchema} initialFields={(assembled?.page.fields as Record<string, unknown>) ?? {}} onDirtyChange={reportDirty} onError={setErr} />
542
+ <PageFields api={api} page={page} schema={pageSchema} initialFields={(assembled?.page.fields as FieldValues) ?? {}} onDirtyChange={reportDirty} onError={setErr} />
543
543
  ) : null}
544
544
  {regions.map((r) => {
545
545
  const blocks = assembled?.regions[r.name] ?? [];
@@ -611,7 +611,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
611
611
  isLast: boolean;
612
612
  onMove: (dir: number) => void;
613
613
  onRemove: () => void;
614
- onPatch: (placementId: string, fields: Record<string, unknown>) => void;
614
+ onPatch: (placementId: string, fields: FieldValues) => void;
615
615
  onDirtyChange: (placementId: string, dirty: boolean) => void;
616
616
  onError: (s: string) => void;
617
617
  dragging: boolean;
@@ -621,7 +621,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
621
621
  onDropBlock: () => void;
622
622
  onDragEndBlock: () => void;
623
623
  }) {
624
- const [fields, setFields] = useState<Record<string, unknown> | null>(null);
624
+ const [fields, setFields] = useState<FieldValues | null>(null);
625
625
  const [collapsed, setCollapsed] = useState(false);
626
626
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
627
627
  const saved = useRef<string>(""); // JSON of the last-persisted fields — the dirty baseline
@@ -635,7 +635,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
635
635
  useEffect(() => {
636
636
  if (block.pending) { setFields({}); saved.current = "{}"; return; }
637
637
  let alive = true;
638
- api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => {
638
+ api.call<{ fields?: FieldValues }>("getBlock", { blockId }).then((b) => {
639
639
  if (!alive) return;
640
640
  const f = b?.fields ?? {};
641
641
  setFields(f);
@@ -680,7 +680,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
680
680
  return () => clearTimeout(t);
681
681
  }, [fields, block.pending]);
682
682
 
683
- const change = (next: Record<string, unknown>) => setFields(next);
683
+ const change = (next: FieldValues) => setFields(next);
684
684
 
685
685
  // Report dirty state up (for the editor's leave/unload guard); clear it on unmount so a
686
686
  // removed block never leaves a stale "unsaved" flag behind.
@@ -891,8 +891,8 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
891
891
  }
892
892
 
893
893
  /** The page's own FIELDS — its content. Rendered in the canvas, at full width. */
894
- function PageFields({ api, page, schema, initialFields, onDirtyChange, onError }: { api: Api; page: Page; schema: FieldDefinition[]; initialFields: Record<string, unknown>; onDirtyChange: (id: string, dirty: boolean) => void; onError: (s: string) => void }) {
895
- const [fields, setFields] = useState<Record<string, unknown>>(initialFields);
894
+ function PageFields({ api, page, schema, initialFields, onDirtyChange, onError }: { api: Api; page: Page; schema: FieldDefinition[]; initialFields: FieldValues; onDirtyChange: (id: string, dirty: boolean) => void; onError: (s: string) => void }) {
895
+ const [fields, setFields] = useState<FieldValues>(initialFields);
896
896
  const [ok, setOk] = useState(false);
897
897
  const [busy, setBusy] = useState(false);
898
898
 
@@ -1480,7 +1480,7 @@ function plainText(html: string): string {
1480
1480
  .trim();
1481
1481
  }
1482
1482
  /** One-line preview for a collapsed block: the first non-empty string field, tags stripped. */
1483
- function blockPreview(fields: Record<string, unknown>): string {
1483
+ function blockPreview(fields: FieldValues): string {
1484
1484
  const first = Object.values(fields).find((v) => typeof v === "string" && v.trim());
1485
1485
  if (typeof first !== "string") return "";
1486
1486
  const text = plainText(first);
package/src/fields.tsx CHANGED
@@ -5,7 +5,7 @@ import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Text,
5
5
  import { BlockEditor } from "@podoba/react/editor";
6
6
  import { useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
7
7
  import type { Api } from "./api";
8
- import type { FieldDefinition, Media } from "./types";
8
+ import type { FieldDefinition, FieldValue, FieldValues, Media } from "./types";
9
9
 
10
10
  // Tokenized bare control (podoba's filled-field skin) for the native inputs that
11
11
  // don't map cleanly onto a podoba primitive (number/date/select/file).
@@ -147,8 +147,8 @@ function PublishControl({ value, onChange }: { value: string; onChange: (v: stri
147
147
  );
148
148
  }
149
149
 
150
- export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void; api: Api }) {
151
- const set = (name: string, v: unknown) => onChange({ ...value, [name]: v });
150
+ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: FieldValues; onChange: (v: FieldValues) => void; api: Api }) {
151
+ const set = (name: string, v: FieldValue) => onChange({ ...value, [name]: v });
152
152
  return (
153
153
  <div className="flex flex-col gap-4">
154
154
  {schema.map((def) => (
@@ -171,13 +171,13 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
171
171
  * accessible-name computation via `aria-labelledby`. Those three go through the bare
172
172
  * `CONTROL` skin instead, the same way `number`/`date` already do.
173
173
  */
174
- function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api; hideLabelAs?: string; siblings?: Record<string, unknown> }) {
174
+ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: FieldValue; onChange: (v: FieldValue) => void; api: Api; hideLabelAs?: string; siblings?: FieldValues }) {
175
175
  const label: ReactNode = hideLabelAs !== undefined ? undefined : (
176
176
  <>
177
177
  {def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
178
178
  </>
179
179
  );
180
- const asText = (v: unknown) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
180
+ const asText = (v: FieldValue) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
181
181
  switch (def.type) {
182
182
  case "text":
183
183
  case "url": {
@@ -258,12 +258,12 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
258
258
  return (
259
259
  <FieldShell label={label}>
260
260
  <div className="rounded-lg border border-border bg-surface-muted p-3.5">
261
- <FieldForm schema={def.fields ?? []} value={(value as Record<string, unknown>) ?? {}} onChange={onChange as (v: Record<string, unknown>) => void} api={api} />
261
+ <FieldForm schema={def.fields ?? []} value={(value as FieldValues) ?? {}} onChange={onChange as (v: FieldValues) => void} api={api} />
262
262
  </div>
263
263
  </FieldShell>
264
264
  );
265
265
  case "repeater":
266
- return <Repeater def={def} value={(value as Record<string, unknown>[]) ?? []} onChange={onChange as (v: unknown[]) => void} api={api} label={label} />;
266
+ return <Repeater def={def} value={(value as FieldValues[]) ?? []} onChange={onChange as (v: FieldValues[]) => void} api={api} label={label} />;
267
267
  default:
268
268
  return null;
269
269
  }
@@ -292,7 +292,7 @@ export function RichText({ value, onChange }: { value: string; onChange: (v: str
292
292
  * Reordering is drag-and-drop from the ⠿ handle, mirroring the block canvas. ↑/↓ stay,
293
293
  * because dragging is unavailable to keyboard users and awkward on touch.
294
294
  */
295
- function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: ReactNode }) {
295
+ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: FieldValues[]; onChange: (v: FieldValues[]) => void; api: Api; label: ReactNode }) {
296
296
  const items = Array.isArray(value) ? value : [];
297
297
  const fields = def.fields ?? [];
298
298
  // One field, and not itself a tall control — the case where the card is pure overhead.
@@ -300,7 +300,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
300
300
 
301
301
  const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
302
302
 
303
- const upd = (i: number, v: Record<string, unknown>) => onChange(items.map((it, j) => (j === i ? v : it)));
303
+ const upd = (i: number, v: FieldValues) => onChange(items.map((it, j) => (j === i ? v : it)));
304
304
  const add = () => onChange([...items, {}]);
305
305
  const del = (i: number) => onChange(items.filter((_, j) => j !== i));
306
306
  const moveTo = (from: number, to: number) => {
@@ -322,7 +322,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
322
322
  * instead titled every slide with the uuid of its image, which is worse than no summary
323
323
  * at all — the point is to tell two rows apart at a glance.
324
324
  */
325
- const summarise = (it: Record<string, unknown>): string => {
325
+ const summarise = (it: FieldValues): string => {
326
326
  const readable = ["text", "textarea", "richtext", "select", "url"];
327
327
  for (const f of fields) {
328
328
  if (!readable.includes(f.type)) continue;
@@ -545,7 +545,7 @@ function SlugField({ def, label, value, source, onChange }: { def: FieldDefiniti
545
545
  // A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
546
546
  // fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
547
547
  // campaigns — so the editor never has to hardcode or copy identifiers by hand.
548
- function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: unknown) => void; api: Api; ariaLabel?: string }) {
548
+ function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: FieldValue) => void; api: Api; ariaLabel?: string }) {
549
549
  const [dyn, setDyn] = useState<{ value: string; label: string }[] | null>(null);
550
550
  const from = def.optionsFrom;
551
551
  useEffect(() => {
package/src/types.ts CHANGED
@@ -2,6 +2,24 @@
2
2
  // @pramen/cms) so the editor stays a self-contained browser app with no server-package
3
3
  // dependency — it speaks to the CMS purely over HTTP.
4
4
 
5
+ /** Any JSON value — the wire form of everything the CMS stores. */
6
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
7
+
8
+ /** One authored field value. Mirrors `FieldValue` in @pramen/cms: a `"media"` field
9
+ * arrives resolved to a `Media`, and `group`/`repeater` fields nest further bags. */
10
+ export type FieldValue = JsonValue | Media | FieldValues | FieldValue[];
11
+
12
+ /** A block / collection / page `fields` bag — field name -> authored value. */
13
+ export interface FieldValues {
14
+ [field: string]: FieldValue;
15
+ }
16
+
17
+ /** The JSON body of an editor RPC call: an object of values, any of which may be
18
+ * omitted (an absent key is simply not sent). */
19
+ export interface RpcInput {
20
+ [key: string]: FieldValue | undefined;
21
+ }
22
+
5
23
  export type FieldType =
6
24
  | "text"
7
25
  | "textarea"
@@ -25,7 +43,7 @@ export interface FieldDefinition {
25
43
  label?: string;
26
44
  type: FieldType;
27
45
  required?: boolean;
28
- default?: unknown;
46
+ default?: FieldValue;
29
47
  fields?: FieldDefinition[];
30
48
  min?: number;
31
49
  max?: number;
@@ -47,7 +65,7 @@ export interface RegionDefinition {
47
65
  export interface DefaultBlockDefinition {
48
66
  region: string;
49
67
  blockTypeSlug: string;
50
- fields?: Record<string, unknown>;
68
+ fields?: FieldValues;
51
69
  }
52
70
 
53
71
  export interface BlockType {
@@ -94,7 +112,7 @@ export interface Page {
94
112
  slug: string;
95
113
  status: string;
96
114
  locale: string;
97
- fields?: Record<string, unknown> | null; // content-type-level structured data (fieldsSchema)
115
+ fields?: FieldValues | null; // content-type-level structured data (fieldsSchema)
98
116
  translationGroupId?: string | null;
99
117
  metaTitle?: string | null;
100
118
  metaDescription?: string | null;
@@ -122,13 +140,13 @@ export interface RenderedBlock {
122
140
  block_id: string; // block instance id (edit)
123
141
  block_type: string;
124
142
  title: string | null;
125
- fields: Record<string, unknown>;
143
+ fields: FieldValues;
126
144
  is_shared: boolean;
127
145
  pending?: boolean; // optimistic placeholder — not yet persisted (temp ids, no getBlock)
128
146
  }
129
147
 
130
148
  export interface AssembledPage {
131
- page: Page & { translations?: { locale: string; slug: string }[]; seo?: Record<string, unknown> };
149
+ page: Page & { translations?: { locale: string; slug: string }[]; seo?: FieldValues };
132
150
  regions: Record<string, RenderedBlock[]>;
133
151
  }
134
152