@pramen/cms-editor 0.0.26 → 0.0.27

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.26",
3
+ "version": "0.0.27",
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": {
@@ -3,7 +3,7 @@
3
3
  // the app context and wire URL params + navigation into these components.
4
4
 
5
5
  import { Button, Input, Textarea } from "@podoba/react";
6
- import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
6
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
7
  import { Api, ApiError } from "./api";
8
8
  import { FieldForm } from "./fields";
9
9
  import type { Config } from "./api";
@@ -163,7 +163,6 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
163
163
  export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void }) {
164
164
  const [ct, setCt] = useState<ContentType | null>(null);
165
165
  const [assembled, setAssembled] = useState<AssembledPage | null>(null);
166
- const [selected, setSelected] = useState<RenderedBlock | null>(null);
167
166
  const [err, setErr] = useState("");
168
167
  const [msg, setMsg] = useState("");
169
168
 
@@ -178,10 +177,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
178
177
  setErr(errMsg(e));
179
178
  }
180
179
  }, [api, page.typeId, page.slug, page.locale]);
181
- useEffect(() => {
182
- reload();
183
- setSelected(null);
184
- }, [reload]);
180
+ useEffect(() => { reload(); }, [reload]);
185
181
 
186
182
  const regions: RegionDefinition[] = ct?.regions ?? [];
187
183
  const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
@@ -198,7 +194,6 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
198
194
  const removeBlock = async (b: RenderedBlock) => {
199
195
  try {
200
196
  await api.call("removeBlock", { pageBlockId: b.id });
201
- if (selected?.id === b.id) setSelected(null);
202
197
  await reload();
203
198
  } catch (e) {
204
199
  setErr(errMsg(e));
@@ -213,6 +208,17 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
213
208
  }
214
209
  };
215
210
 
211
+ // Patch a block's raw fields into local state after an inline save — keeps the collapsed
212
+ // preview fresh without a full reload (which would remount every editor + lose caret/focus).
213
+ const patchBlockFields = useCallback((placementId: string, fields: Record<string, unknown>) => {
214
+ setAssembled((prev) => {
215
+ if (!prev) return prev;
216
+ const next: Record<string, RenderedBlock[]> = {};
217
+ for (const [name, list] of Object.entries(prev.regions)) next[name] = list.map((b) => (b.id === placementId ? { ...b, fields } : b));
218
+ return { ...prev, regions: next };
219
+ });
220
+ }, []);
221
+
216
222
  return (
217
223
  <div className="grid min-h-[calc(100vh-68px)] grid-cols-[260px_1fr_400px] gap-5 px-7 pb-7 pt-2 max-[820px]:grid-cols-1">
218
224
  <div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
@@ -238,28 +244,24 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
238
244
  const blocks = assembled?.regions[r.name] ?? [];
239
245
  const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
240
246
  return (
241
- <div className="mb-6" key={r.name}>
242
- <h3 className="m-0 mb-3 flex items-baseline gap-3 text-[22px] font-normal text-fg">
243
- {r.label ?? r.name}
244
- {r.allowedTypes ? <span className="text-[11px] font-normal text-fg-subtle">only: {r.allowedTypes.join(", ")}</span> : null}
245
- </h3>
247
+ <div className="mb-8" key={r.name}>
248
+ <h3 className="m-0 mb-3 text-[22px] font-normal text-fg">{r.label ?? r.name}</h3>
249
+ {blocks.length === 0 ? <p className="mb-3 text-sm text-fg-subtle">No blocks yet — add one below.</p> : null}
246
250
  {blocks.map((b, i) => (
247
- <div className={`mb-2.5 rounded-panel border bg-surface-card p-3.5 ${selected?.id === b.id ? "border-fg" : "border-border"}`} key={b.id} onClick={() => setSelected(b)}>
248
- <div className="flex items-center gap-2.5">
249
- <span className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg-muted">{b.block_type}</span>
250
- {b.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
251
- <span className="flex-1 truncate text-fg-subtle">{summarize(b.fields)}</span>
252
- <Button variant="ghost" size="sm" onPress={() => reorderMove(blocks, r.name, i, -1, reorder)}>↑</Button>
253
- <Button variant="ghost" size="sm" onPress={() => reorderMove(blocks, r.name, i, 1, reorder)}>↓</Button>
254
- <Button variant="ghost" size="sm" className="text-danger" onPress={() => removeBlock(b)}>✕</Button>
255
- </div>
256
- </div>
251
+ <BlockCard
252
+ key={b.id}
253
+ api={api}
254
+ block={b}
255
+ blockType={btBySlug.get(b.block_type)}
256
+ isFirst={i === 0}
257
+ isLast={i === blocks.length - 1}
258
+ onMove={(d) => reorderMove(blocks, r.name, i, d, reorder)}
259
+ onRemove={() => removeBlock(b)}
260
+ onPatch={patchBlockFields}
261
+ onError={setErr}
262
+ />
257
263
  ))}
258
- <div className="mt-2.5 flex flex-wrap gap-1.5">
259
- {allowed.map((slug) => (
260
- <Button key={slug} variant="secondary" size="sm" onPress={() => addBlock(r.name, slug)}>+ {btBySlug.get(slug)?.name ?? slug}</Button>
261
- ))}
262
- </div>
264
+ <AddBlock allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
263
265
  </div>
264
266
  );
265
267
  })}
@@ -267,55 +269,130 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
267
269
  </div>
268
270
 
269
271
  <div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
270
- {selected ? (
271
- <BlockInspector api={api} block={selected} blockType={btBySlug.get(selected.block_type)} onClose={() => setSelected(null)} onSaved={reload} onError={setErr} />
272
- ) : (
273
- <>
274
- <div className="mb-3 flex gap-1">
275
- {INSPECTOR_TABS.map((t) => (
276
- <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
277
- ))}
278
- </div>
279
- {tab === "settings" ? <Settings page={page} /> : null}
280
- {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
281
- {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
282
- {tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
283
- {tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
284
- </>
285
- )}
272
+ <div className="mb-3 flex gap-1">
273
+ {INSPECTOR_TABS.map((t) => (
274
+ <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
275
+ ))}
276
+ </div>
277
+ {tab === "settings" ? <Settings page={page} /> : null}
278
+ {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
279
+ {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
280
+ {tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
281
+ {tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
286
282
  </div>
287
283
  </div>
288
284
  );
289
285
  }
290
286
 
291
- function BlockInspector({ api, block, blockType, onClose, onSaved, onError }: { api: Api; block: RenderedBlock; blockType: BlockType | undefined; onClose: () => void; onSaved: () => void; onError: (s: string) => void }) {
292
- const [fields, setFields] = useState<Record<string, unknown>>({});
293
- const [busy, setBusy] = useState(false);
287
+ // A single block, edited inline: the block IS its editor. Fields render in place (rich text
288
+ // as the WYSIWYG, media as a thumbnail picker), and edits autosave — debounced while typing,
289
+ // flushed on blur and on unmount — so there's no separate "save block" step and no full page
290
+ // reload that would drop the caret. Collapse folds it to a one-line plain-text preview.
291
+ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError }: {
292
+ api: Api;
293
+ block: RenderedBlock;
294
+ blockType: BlockType | undefined;
295
+ isFirst: boolean;
296
+ isLast: boolean;
297
+ onMove: (dir: number) => void;
298
+ onRemove: () => void;
299
+ onPatch: (placementId: string, fields: Record<string, unknown>) => void;
300
+ onError: (s: string) => void;
301
+ }) {
302
+ const [fields, setFields] = useState<Record<string, unknown> | null>(null);
303
+ const [collapsed, setCollapsed] = useState(false);
304
+ const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
305
+ const pending = useRef<Record<string, unknown> | null>(null);
306
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
307
+ const blockId = block.block_id;
308
+ const placementId = block.id;
309
+
310
+ // Load RAW fields (media as ids, richtext as HTML) so the value round-trips on save.
294
311
  useEffect(() => {
295
- // Load RAW fields (media as ids) for editing.
296
- api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId: block.block_id }).then((b) => setFields(b?.fields ?? {})).catch((e) => onError(errMsg(e)));
297
- }, [api, block.block_id, onError]);
298
- const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
299
- const save = async () => {
300
- setBusy(true);
312
+ let alive = true;
313
+ api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => { if (alive) setFields(b?.fields ?? {}); }).catch((e) => onError(errMsg(e)));
314
+ return () => { alive = false; };
315
+ }, [api, blockId, onError]);
316
+
317
+ const flush = useCallback(async () => {
318
+ if (timer.current) { clearTimeout(timer.current); timer.current = undefined; }
319
+ const next = pending.current;
320
+ if (!next) return;
321
+ pending.current = null;
322
+ setSaveState("saving");
301
323
  try {
302
- await api.call("updateBlock", { blockId: block.block_id, fields });
303
- onSaved();
324
+ await api.call("updateBlock", { blockId, fields: next });
325
+ onPatch(placementId, next);
326
+ setSaveState("saved");
327
+ setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1200);
304
328
  } catch (e) {
305
329
  onError(errMsg(e));
306
- } finally {
307
- setBusy(false);
330
+ setSaveState("idle");
308
331
  }
332
+ }, [api, blockId, placementId, onPatch, onError]);
333
+
334
+ // Flush a pending edit if the card unmounts (nav away, reorder remount) before the debounce.
335
+ useEffect(() => () => { if (pending.current) void flush(); }, [flush]);
336
+
337
+ const change = (next: Record<string, unknown>) => {
338
+ setFields(next);
339
+ pending.current = next;
340
+ if (timer.current) clearTimeout(timer.current);
341
+ timer.current = setTimeout(() => void flush(), 700);
309
342
  };
343
+
344
+ const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
345
+ const name = blockType?.name ?? block.block_type;
346
+
310
347
  return (
311
- <div>
312
- <div className="flex items-center gap-2">
313
- <span className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg-muted">{block.block_type}</span>
348
+ <div className="mb-2.5 rounded-panel border border-border bg-surface-card">
349
+ <div className="flex items-center gap-2.5 px-3.5 py-2.5">
350
+ <button type="button" className="w-4 shrink-0 text-fg-subtle hover:text-fg" title={collapsed ? "Expand" : "Collapse"} onClick={() => setCollapsed((c) => !c)}>{collapsed ? "▸" : "▾"}</button>
351
+ <span className="font-medium text-fg">{name}</span>
352
+ {block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
353
+ <span className="text-[11px] text-fg-subtle">{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : ""}</span>
314
354
  <span className="flex-1" />
315
- <Button variant="ghost" size="sm" onPress={onClose}>done</Button>
355
+ <Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
356
+ <Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
357
+ <Button variant="ghost" size="sm" className="text-danger" onPress={onRemove}>✕</Button>
358
+ </div>
359
+ {collapsed ? (
360
+ <div className="cursor-pointer truncate px-3.5 pb-3 text-sm text-fg-subtle" onClick={() => setCollapsed(false)}>
361
+ {fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
362
+ </div>
363
+ ) : (
364
+ // onBlur bubbles from the inner inputs — leaving the block flushes any pending edit
365
+ // immediately (flush() no-ops when nothing is pending, so tabbing between fields is free).
366
+ <div className="border-t border-border px-3.5 py-3.5" onBlur={() => void flush()}>
367
+ {fields == null ? (
368
+ <p className="text-sm text-fg-subtle">loading…</p>
369
+ ) : schema.length === 0 ? (
370
+ <p className="text-sm text-fg-subtle">This block has no editable fields.</p>
371
+ ) : (
372
+ <FieldForm schema={schema} value={fields} onChange={change} api={api} />
373
+ )}
374
+ </div>
375
+ )}
376
+ </div>
377
+ );
378
+ }
379
+
380
+ // "+ Add block" reveals a compact picker of the region's allowed types (by friendly name),
381
+ // replacing the always-on row of every type as a wall of buttons.
382
+ function AddBlock({ allowed, btBySlug, onAdd }: { allowed: string[]; btBySlug: Map<string, BlockType>; onAdd: (slug: string) => void }) {
383
+ const [open, setOpen] = useState(false);
384
+ if (!open) return <Button variant="secondary" size="sm" onPress={() => setOpen(true)}>+ Add block</Button>;
385
+ return (
386
+ <div className="rounded-panel border border-border bg-surface-card p-2.5">
387
+ <div className="mb-1.5 px-1 text-[11px] text-fg-subtle">Add a block</div>
388
+ <div className="flex flex-wrap gap-1.5">
389
+ {allowed.map((slug) => (
390
+ <Button key={slug} variant="ghost" size="sm" onPress={() => { onAdd(slug); setOpen(false); }}>{btBySlug.get(slug)?.name ?? slug}</Button>
391
+ ))}
392
+ </div>
393
+ <div className="mt-1.5 text-right">
394
+ <Button variant="ghost" size="sm" className="text-fg-subtle" onPress={() => setOpen(false)}>cancel</Button>
316
395
  </div>
317
- {schema.length === 0 ? <p className="text-fg-subtle">This block type has no fields.</p> : <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />}
318
- <Button className="mt-3 w-full" onPress={save} isDisabled={busy}>Save block</Button>
319
396
  </div>
320
397
  );
321
398
  }
@@ -856,9 +933,24 @@ export function errMsg(e: unknown): string {
856
933
  function slugify(s: string): string {
857
934
  return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
858
935
  }
859
- function summarize(fields: Record<string, unknown>): string {
860
- const first = Object.values(fields).find((v) => typeof v === "string" && v);
861
- return typeof first === "string" ? (first.length > 48 ? first.slice(0, 48) + "…" : first) : "";
936
+ /** Strip HTML tags + decode the few entities the WYSIWYG emits, for a clean text preview —
937
+ * so a collapsed rich_text block reads "Test Toakdopwad" instead of "<b>Test</b>&nbsp;…". */
938
+ function plainText(html: string): string {
939
+ return html
940
+ .replace(/<[^>]*>/g, " ")
941
+ .replace(/&nbsp;/g, " ")
942
+ .replace(/&amp;/g, "&")
943
+ .replace(/&lt;/g, "<")
944
+ .replace(/&gt;/g, ">")
945
+ .replace(/\s+/g, " ")
946
+ .trim();
947
+ }
948
+ /** One-line preview for a collapsed block: the first non-empty string field, tags stripped. */
949
+ function blockPreview(fields: Record<string, unknown>): string {
950
+ const first = Object.values(fields).find((v) => typeof v === "string" && v.trim());
951
+ if (typeof first !== "string") return "";
952
+ const text = plainText(first);
953
+ return text.length > 90 ? text.slice(0, 90) + "…" : text;
862
954
  }
863
955
  function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: number, reorder: (region: string, order: string[]) => void) {
864
956
  const j = i + d;