@pramen/cms-editor 0.0.47 → 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.47",
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 {
@@ -5,10 +5,10 @@
5
5
  import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textarea } from "@podoba/react";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
7
  import { Api, ApiError } from "./api";
8
- import { FieldForm } from "./fields";
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
 
@@ -1467,9 +1467,6 @@ export function errMsg(e: unknown): string {
1467
1467
  if (e instanceof ApiError) return e.message;
1468
1468
  return e instanceof Error ? e.message : String(e);
1469
1469
  }
1470
- function slugify(s: string): string {
1471
- return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1472
- }
1473
1470
  /** Strip HTML tags + decode the few entities the WYSIWYG emits, for a clean text preview —
1474
1471
  * so a collapsed rich_text block reads "Test Toakdopwad" instead of "<b>Test</b>&nbsp;…". */
1475
1472
  function plainText(html: string): string {
@@ -1483,7 +1480,7 @@ function plainText(html: string): string {
1483
1480
  .trim();
1484
1481
  }
1485
1482
  /** One-line preview for a collapsed block: the first non-empty string field, tags stripped. */
1486
- function blockPreview(fields: Record<string, unknown>): string {
1483
+ function blockPreview(fields: FieldValues): string {
1487
1484
  const first = Object.values(fields).find((v) => typeof v === "string" && v.trim());
1488
1485
  if (typeof first !== "string") return "";
1489
1486
  const text = plainText(first);
package/src/fields.tsx CHANGED
@@ -3,9 +3,9 @@
3
3
 
4
4
  import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Text, Textarea } from "@podoba/react";
5
5
  import { BlockEditor } from "@podoba/react/editor";
6
- import { useEffect, useState, type DragEvent, type ReactNode } from "react";
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,12 +147,13 @@ 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) => (
155
- <FieldInput key={def.name} def={def} value={value[def.name]} onChange={(v) => set(def.name, v)} api={api} />
155
+ // `siblings` is only read by field types that derive from another field (slug).
156
+ <FieldInput key={def.name} def={def} value={value[def.name]} onChange={(v) => set(def.name, v)} api={api} siblings={value} />
156
157
  ))}
157
158
  </div>
158
159
  );
@@ -170,13 +171,13 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
170
171
  * accessible-name computation via `aria-labelledby`. Those three go through the bare
171
172
  * `CONTROL` skin instead, the same way `number`/`date` already do.
172
173
  */
173
- function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api; hideLabelAs?: string }) {
174
+ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: FieldValue; onChange: (v: FieldValue) => void; api: Api; hideLabelAs?: string; siblings?: FieldValues }) {
174
175
  const label: ReactNode = hideLabelAs !== undefined ? undefined : (
175
176
  <>
176
177
  {def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
177
178
  </>
178
179
  );
179
- 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));
180
181
  switch (def.type) {
181
182
  case "text":
182
183
  case "url": {
@@ -214,6 +215,16 @@ function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefi
214
215
  <input className={CONTROL} type={def.type === "date" ? "date" : "datetime-local"} aria-label={hideLabelAs} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)} />
215
216
  </FieldShell>
216
217
  );
218
+ case "slug":
219
+ return (
220
+ <SlugField
221
+ def={def}
222
+ label={label}
223
+ value={typeof value === "string" ? value : ""}
224
+ source={typeof siblings?.[def.from ?? ""] === "string" ? (siblings[def.from ?? ""] as string) : ""}
225
+ onChange={onChange as (v: string) => void}
226
+ />
227
+ );
217
228
  case "publish":
218
229
  // Deliberately NOT FieldShell: it wraps children in a <label>, whose implicit
219
230
  // control would be PublishControl's first <button> ("Publish now") — clicking the
@@ -247,12 +258,12 @@ function FieldInput({ def, value, onChange, api, hideLabelAs }: { def: FieldDefi
247
258
  return (
248
259
  <FieldShell label={label}>
249
260
  <div className="rounded-lg border border-border bg-surface-muted p-3.5">
250
- <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} />
251
262
  </div>
252
263
  </FieldShell>
253
264
  );
254
265
  case "repeater":
255
- 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} />;
256
267
  default:
257
268
  return null;
258
269
  }
@@ -281,7 +292,7 @@ export function RichText({ value, onChange }: { value: string; onChange: (v: str
281
292
  * Reordering is drag-and-drop from the ⠿ handle, mirroring the block canvas. ↑/↓ stay,
282
293
  * because dragging is unavailable to keyboard users and awkward on touch.
283
294
  */
284
- 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 }) {
285
296
  const items = Array.isArray(value) ? value : [];
286
297
  const fields = def.fields ?? [];
287
298
  // One field, and not itself a tall control — the case where the card is pure overhead.
@@ -289,7 +300,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
289
300
 
290
301
  const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
291
302
 
292
- 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)));
293
304
  const add = () => onChange([...items, {}]);
294
305
  const del = (i: number) => onChange(items.filter((_, j) => j !== i));
295
306
  const moveTo = (from: number, to: number) => {
@@ -311,7 +322,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
311
322
  * instead titled every slide with the uuid of its image, which is worse than no summary
312
323
  * at all — the point is to tell two rows apart at a glance.
313
324
  */
314
- const summarise = (it: Record<string, unknown>): string => {
325
+ const summarise = (it: FieldValues): string => {
315
326
  const readable = ["text", "textarea", "richtext", "select", "url"];
316
327
  for (const f of fields) {
317
328
  if (!readable.includes(f.type)) continue;
@@ -431,10 +442,110 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
431
442
  );
432
443
  }
433
444
 
445
+ /**
446
+ * Turn a title into a URL segment.
447
+ *
448
+ * Diacritics are decomposed and their marks dropped, so Czech text transliterates the way
449
+ * a reader expects — "Vrátnice výrobního závodu" becomes "vratnice-vyrobniho-zavodu"
450
+ * rather than losing the accented letters entirely.
451
+ */
452
+ export function slugify(input: string): string {
453
+ // Trailing trim AFTER the length cap — slicing a hyphen-terminated prefix out of a long
454
+ // title would otherwise leave the slug ending in "-".
455
+ return slugifyInput(input).replace(/-+$/, "");
456
+ }
457
+
458
+ /**
459
+ * Per-keystroke normalization: everything `slugify` does EXCEPT the trailing-hyphen trim.
460
+ *
461
+ * The input is controlled, so anything this strips can never be typed at all — trimming the
462
+ * trailing "-" here would make a separator unenterable (type "my-", `slugify` hands back
463
+ * "my", the value prop never changes, React restores the DOM). The full `slugify` runs on
464
+ * blur instead, once the word is finished.
465
+ */
466
+ function slugifyInput(input: string): string {
467
+ return input
468
+ .normalize("NFD")
469
+ .replace(/[\u0300-\u036f]/g, "")
470
+ .toLowerCase()
471
+ .replace(/[^a-z0-9-]+/g, "-")
472
+ .replace(/-{2,}/g, "-")
473
+ .replace(/^-+/, "")
474
+ .slice(0, 80);
475
+ }
476
+
477
+ /**
478
+ * A slug that follows another field while it is untouched.
479
+ *
480
+ * It deliberately stops following the moment the value differs from what it last derived
481
+ * — which includes every row that already has a slug. Renaming a published project must
482
+ * not silently change its URL and break every link to it; "Generate from …" is there for
483
+ * when that IS what you want.
484
+ */
485
+ function SlugField({ def, label, value, source, onChange }: { def: FieldDefinition; label: ReactNode; value: string; source: string; onChange: (v: string) => void }) {
486
+ // What this control last wrote. While the field still holds it, the field is "untouched"
487
+ // and free to follow; anything else means a human typed it.
488
+ const derived = useRef<string | null>(null);
489
+ // Whether a human has edited this field. Once true it stays true for the session —
490
+ // emptying the field is an edit, not an invitation to start following again.
491
+ const touched = useRef(false);
492
+ // The source we last saw. Seeded on the first run so merely OPENING a row never derives:
493
+ // a row saved before this field existed has a filled title and an empty slug, and an
494
+ // onChange there marks the block dirty and autosaves something nobody asked for. Only a
495
+ // real change to the source field derives; "Generate from …" covers the rest.
496
+ const lastSource = useRef<string | null>(null);
497
+
498
+ useEffect(() => {
499
+ if (!def.from) return;
500
+ const prev = lastSource.current;
501
+ lastSource.current = source;
502
+ if (prev === null || prev === source || !source) return;
503
+ if (touched.current) return;
504
+ // Follow only an empty field or one still holding our own last derivation — an existing
505
+ // slug is a live URL, and renaming its page must not break every link to it.
506
+ if (value !== "" && value !== derived.current) return;
507
+ const next = slugify(source);
508
+ if (next === value) return;
509
+ derived.current = next;
510
+ onChange(next);
511
+ }, [source, value, def.from, onChange]);
512
+
513
+ const suggestion = source ? slugify(source) : "";
514
+ const canGenerate = Boolean(suggestion) && suggestion !== value;
515
+
516
+ return (
517
+ <FieldShell label={label}>
518
+ <input
519
+ className={CONTROL}
520
+ value={value}
521
+ onChange={(e) => {
522
+ touched.current = true; // typed by hand — stop following from here on
523
+ derived.current = null;
524
+ onChange(slugifyInput(e.target.value));
525
+ }}
526
+ onBlur={() => {
527
+ const clean = slugify(value);
528
+ if (clean !== value) onChange(clean);
529
+ }}
530
+ placeholder={suggestion}
531
+ />
532
+ {canGenerate ? (
533
+ <button
534
+ type="button"
535
+ className="self-start text-caption text-fg-subtle underline hover:text-fg"
536
+ onClick={() => { derived.current = suggestion; touched.current = false; onChange(suggestion); }}
537
+ >
538
+ Generate from {def.from}: {suggestion}
539
+ </button>
540
+ ) : null}
541
+ </FieldShell>
542
+ );
543
+ }
544
+
434
545
  // A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
435
546
  // fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
436
547
  // campaigns — so the editor never has to hardcode or copy identifiers by hand.
437
- 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 }) {
438
549
  const [dyn, setDyn] = useState<{ value: string; label: string }[] | null>(null);
439
550
  const from = def.optionsFrom;
440
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"
@@ -13,6 +31,8 @@ export type FieldType =
13
31
  | "datetime"
14
32
  /** A publication timestamp — rendered as publish-now / schedule / unpublish. */
15
33
  | "publish"
34
+ /** A URL segment, derived from the field named by `from` while it is untouched. */
35
+ | "slug"
16
36
  | "media"
17
37
  | "select"
18
38
  | "repeater"
@@ -23,7 +43,7 @@ export interface FieldDefinition {
23
43
  label?: string;
24
44
  type: FieldType;
25
45
  required?: boolean;
26
- default?: unknown;
46
+ default?: FieldValue;
27
47
  fields?: FieldDefinition[];
28
48
  min?: number;
29
49
  max?: number;
@@ -32,6 +52,8 @@ export interface FieldDefinition {
32
52
  * return `{ value, label }[]`. Lets a select offer live data (e.g. existing campaigns)
33
53
  * instead of a static list. Takes precedence over `options`. */
34
54
  optionsFrom?: string;
55
+ /** For `slug`: the sibling field this one is derived from (e.g. `"title"`). */
56
+ from?: string;
35
57
  }
36
58
 
37
59
  export interface RegionDefinition {
@@ -43,7 +65,7 @@ export interface RegionDefinition {
43
65
  export interface DefaultBlockDefinition {
44
66
  region: string;
45
67
  blockTypeSlug: string;
46
- fields?: Record<string, unknown>;
68
+ fields?: FieldValues;
47
69
  }
48
70
 
49
71
  export interface BlockType {
@@ -90,7 +112,7 @@ export interface Page {
90
112
  slug: string;
91
113
  status: string;
92
114
  locale: string;
93
- fields?: Record<string, unknown> | null; // content-type-level structured data (fieldsSchema)
115
+ fields?: FieldValues | null; // content-type-level structured data (fieldsSchema)
94
116
  translationGroupId?: string | null;
95
117
  metaTitle?: string | null;
96
118
  metaDescription?: string | null;
@@ -118,13 +140,13 @@ export interface RenderedBlock {
118
140
  block_id: string; // block instance id (edit)
119
141
  block_type: string;
120
142
  title: string | null;
121
- fields: Record<string, unknown>;
143
+ fields: FieldValues;
122
144
  is_shared: boolean;
123
145
  pending?: boolean; // optimistic placeholder — not yet persisted (temp ids, no getBlock)
124
146
  }
125
147
 
126
148
  export interface AssembledPage {
127
- page: Page & { translations?: { locale: string; slug: string }[]; seo?: Record<string, unknown> };
149
+ page: Page & { translations?: { locale: string; slug: string }[]; seo?: FieldValues };
128
150
  regions: Record<string, RenderedBlock[]>;
129
151
  }
130
152