@pramen/cms-editor 0.0.26 → 0.0.28

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.28",
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,33 @@ 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
+
222
+ // Drag-to-reorder within a region. `from`/`over` are indices in the region's block list;
223
+ // hovering updates `over` live (for the drop-target highlight), and the drop commits the
224
+ // new order via reorderRegion. Dragging is confined to the region it started in.
225
+ const [drag, setDrag] = useState<{ region: string; from: number; over: number } | null>(null);
226
+ const beginDrag = (region: string, i: number) => setDrag({ region, from: i, over: i });
227
+ const hoverDrag = (region: string, i: number) => setDrag((d) => (d && d.region === region && d.over !== i ? { ...d, over: i } : d));
228
+ const cancelDrag = () => setDrag(null);
229
+ const dropDrag = (region: string) => {
230
+ setDrag(null);
231
+ if (!drag || drag.region !== region || drag.from === drag.over) return;
232
+ const ids = (assembled?.regions[region] ?? []).map((b) => b.id);
233
+ const [moved] = ids.splice(drag.from, 1);
234
+ ids.splice(drag.over, 0, moved);
235
+ reorder(region, ids);
236
+ };
237
+
216
238
  return (
217
239
  <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
240
  <div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
@@ -238,28 +260,30 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
238
260
  const blocks = assembled?.regions[r.name] ?? [];
239
261
  const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
240
262
  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>
263
+ <div className="mb-8" key={r.name}>
264
+ <h3 className="m-0 mb-3 text-[22px] font-normal text-fg">{r.label ?? r.name}</h3>
265
+ {blocks.length === 0 ? <p className="mb-3 text-sm text-fg-subtle">No blocks yet — add one below.</p> : null}
246
266
  {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>
267
+ <BlockCard
268
+ key={b.id}
269
+ api={api}
270
+ block={b}
271
+ blockType={btBySlug.get(b.block_type)}
272
+ isFirst={i === 0}
273
+ isLast={i === blocks.length - 1}
274
+ onMove={(d) => reorderMove(blocks, r.name, i, d, reorder)}
275
+ onRemove={() => removeBlock(b)}
276
+ onPatch={patchBlockFields}
277
+ onError={setErr}
278
+ dragging={drag?.region === r.name && drag.from === i}
279
+ isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
280
+ onDragStartBlock={() => beginDrag(r.name, i)}
281
+ onDragOverBlock={() => hoverDrag(r.name, i)}
282
+ onDropBlock={() => dropDrag(r.name)}
283
+ onDragEndBlock={cancelDrag}
284
+ />
257
285
  ))}
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>
286
+ <AddBlock allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
263
287
  </div>
264
288
  );
265
289
  })}
@@ -267,55 +291,157 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
267
291
  </div>
268
292
 
269
293
  <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
- )}
294
+ <div className="mb-3 flex gap-1">
295
+ {INSPECTOR_TABS.map((t) => (
296
+ <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
297
+ ))}
298
+ </div>
299
+ {tab === "settings" ? <Settings page={page} /> : null}
300
+ {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
301
+ {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
302
+ {tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
303
+ {tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
286
304
  </div>
287
305
  </div>
288
306
  );
289
307
  }
290
308
 
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);
309
+ // A single block, edited inline: the block IS its editor. Fields render in place (rich text
310
+ // as the WYSIWYG, media as a thumbnail picker), and edits autosave — debounced while typing,
311
+ // flushed on blur and on unmount — so there's no separate "save block" step and no full page
312
+ // reload that would drop the caret. Collapse folds it to a one-line plain-text preview.
313
+ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError, dragging, isOver, onDragStartBlock, onDragOverBlock, onDropBlock, onDragEndBlock }: {
314
+ api: Api;
315
+ block: RenderedBlock;
316
+ blockType: BlockType | undefined;
317
+ isFirst: boolean;
318
+ isLast: boolean;
319
+ onMove: (dir: number) => void;
320
+ onRemove: () => void;
321
+ onPatch: (placementId: string, fields: Record<string, unknown>) => void;
322
+ onError: (s: string) => void;
323
+ dragging: boolean;
324
+ isOver: boolean;
325
+ onDragStartBlock: () => void;
326
+ onDragOverBlock: () => void;
327
+ onDropBlock: () => void;
328
+ onDragEndBlock: () => void;
329
+ }) {
330
+ const [fields, setFields] = useState<Record<string, unknown> | null>(null);
331
+ const [collapsed, setCollapsed] = useState(false);
332
+ const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
333
+ const pending = useRef<Record<string, unknown> | null>(null);
334
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
335
+ const cardRef = useRef<HTMLDivElement>(null);
336
+ const blockId = block.block_id;
337
+ const placementId = block.id;
338
+
339
+ // Load RAW fields (media as ids, richtext as HTML) so the value round-trips on save.
294
340
  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);
341
+ let alive = true;
342
+ api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => { if (alive) setFields(b?.fields ?? {}); }).catch((e) => onError(errMsg(e)));
343
+ return () => { alive = false; };
344
+ }, [api, blockId, onError]);
345
+
346
+ const flush = useCallback(async () => {
347
+ if (timer.current) { clearTimeout(timer.current); timer.current = undefined; }
348
+ const next = pending.current;
349
+ if (!next) return;
350
+ pending.current = null;
351
+ setSaveState("saving");
301
352
  try {
302
- await api.call("updateBlock", { blockId: block.block_id, fields });
303
- onSaved();
353
+ await api.call("updateBlock", { blockId, fields: next });
354
+ onPatch(placementId, next);
355
+ setSaveState("saved");
356
+ setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1200);
304
357
  } catch (e) {
305
358
  onError(errMsg(e));
306
- } finally {
307
- setBusy(false);
359
+ setSaveState("idle");
308
360
  }
361
+ }, [api, blockId, placementId, onPatch, onError]);
362
+
363
+ // Flush a pending edit if the card unmounts (nav away, reorder remount) before the debounce.
364
+ useEffect(() => () => { if (pending.current) void flush(); }, [flush]);
365
+
366
+ const change = (next: Record<string, unknown>) => {
367
+ setFields(next);
368
+ pending.current = next;
369
+ if (timer.current) clearTimeout(timer.current);
370
+ timer.current = setTimeout(() => void flush(), 700);
309
371
  };
372
+
373
+ const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
374
+ const name = blockType?.name ?? block.block_type;
375
+
310
376
  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>
377
+ // The card is the drop target; the ⠿ handle is the only draggable element, so
378
+ // dragging never fights the inline contentEditable text selection. The handle's drag
379
+ // image is set to the whole card so you drag a preview of the block, not just the grip.
380
+ <div
381
+ ref={cardRef}
382
+ className={`mb-2.5 rounded-panel border bg-surface-card transition-colors ${isOver ? "border-brand-green" : "border-border"} ${dragging ? "opacity-40" : ""}`}
383
+ onDragOver={(e) => { e.preventDefault(); onDragOverBlock(); }}
384
+ onDrop={(e) => { e.preventDefault(); onDropBlock(); }}
385
+ >
386
+ <div className="flex items-center gap-2.5 px-3.5 py-2.5">
387
+ <span
388
+ draggable
389
+ onDragStart={(e) => {
390
+ if (cardRef.current) e.dataTransfer.setDragImage(cardRef.current, 12, 12);
391
+ e.dataTransfer.effectAllowed = "move";
392
+ e.dataTransfer.setData("text/plain", "");
393
+ onDragStartBlock();
394
+ }}
395
+ onDragEnd={onDragEndBlock}
396
+ className="shrink-0 cursor-grab select-none px-0.5 text-fg-subtle hover:text-fg active:cursor-grabbing"
397
+ title="Drag to reorder"
398
+ >⠿</span>
399
+ <button type="button" className="w-4 shrink-0 text-fg-subtle hover:text-fg" title={collapsed ? "Expand" : "Collapse"} onClick={() => setCollapsed((c) => !c)}>{collapsed ? "▸" : "▾"}</button>
400
+ <span className="font-medium text-fg">{name}</span>
401
+ {block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
402
+ <span className="text-[11px] text-fg-subtle">{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : ""}</span>
314
403
  <span className="flex-1" />
315
- <Button variant="ghost" size="sm" onPress={onClose}>done</Button>
404
+ <Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
405
+ <Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
406
+ <Button variant="ghost" size="sm" className="text-danger" onPress={onRemove}>✕</Button>
407
+ </div>
408
+ {collapsed ? (
409
+ <div className="cursor-pointer truncate px-3.5 pb-3 text-sm text-fg-subtle" onClick={() => setCollapsed(false)}>
410
+ {fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
411
+ </div>
412
+ ) : (
413
+ // onBlur bubbles from the inner inputs — leaving the block flushes any pending edit
414
+ // immediately (flush() no-ops when nothing is pending, so tabbing between fields is free).
415
+ <div className="border-t border-border px-3.5 py-3.5" onBlur={() => void flush()}>
416
+ {fields == null ? (
417
+ <p className="text-sm text-fg-subtle">loading…</p>
418
+ ) : schema.length === 0 ? (
419
+ <p className="text-sm text-fg-subtle">This block has no editable fields.</p>
420
+ ) : (
421
+ <FieldForm schema={schema} value={fields} onChange={change} api={api} />
422
+ )}
423
+ </div>
424
+ )}
425
+ </div>
426
+ );
427
+ }
428
+
429
+ // "+ Add block" reveals a compact picker of the region's allowed types (by friendly name),
430
+ // replacing the always-on row of every type as a wall of buttons.
431
+ function AddBlock({ allowed, btBySlug, onAdd }: { allowed: string[]; btBySlug: Map<string, BlockType>; onAdd: (slug: string) => void }) {
432
+ const [open, setOpen] = useState(false);
433
+ if (!open) return <Button variant="secondary" size="sm" onPress={() => setOpen(true)}>+ Add block</Button>;
434
+ return (
435
+ <div className="rounded-panel border border-border bg-surface-card p-2.5">
436
+ <div className="mb-1.5 px-1 text-[11px] text-fg-subtle">Add a block</div>
437
+ <div className="flex flex-wrap gap-1.5">
438
+ {allowed.map((slug) => (
439
+ <Button key={slug} variant="ghost" size="sm" onPress={() => { onAdd(slug); setOpen(false); }}>{btBySlug.get(slug)?.name ?? slug}</Button>
440
+ ))}
441
+ </div>
442
+ <div className="mt-1.5 text-right">
443
+ <Button variant="ghost" size="sm" className="text-fg-subtle" onPress={() => setOpen(false)}>cancel</Button>
316
444
  </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
445
  </div>
320
446
  );
321
447
  }
@@ -856,9 +982,24 @@ export function errMsg(e: unknown): string {
856
982
  function slugify(s: string): string {
857
983
  return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
858
984
  }
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) : "";
985
+ /** Strip HTML tags + decode the few entities the WYSIWYG emits, for a clean text preview —
986
+ * so a collapsed rich_text block reads "Test Toakdopwad" instead of "<b>Test</b>&nbsp;…". */
987
+ function plainText(html: string): string {
988
+ return html
989
+ .replace(/<[^>]*>/g, " ")
990
+ .replace(/&nbsp;/g, " ")
991
+ .replace(/&amp;/g, "&")
992
+ .replace(/&lt;/g, "<")
993
+ .replace(/&gt;/g, ">")
994
+ .replace(/\s+/g, " ")
995
+ .trim();
996
+ }
997
+ /** One-line preview for a collapsed block: the first non-empty string field, tags stripped. */
998
+ function blockPreview(fields: Record<string, unknown>): string {
999
+ const first = Object.values(fields).find((v) => typeof v === "string" && v.trim());
1000
+ if (typeof first !== "string") return "";
1001
+ const text = plainText(first);
1002
+ return text.length > 90 ? text.slice(0, 90) + "…" : text;
862
1003
  }
863
1004
  function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: number, reorder: (region: string, order: string[]) => void) {
864
1005
  const j = i + d;