@pramen/cms-editor 0.0.29 → 0.0.31

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.29",
3
+ "version": "0.0.31",
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": {
@@ -164,7 +164,6 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
164
164
  const [ct, setCt] = useState<ContentType | null>(null);
165
165
  const [assembled, setAssembled] = useState<AssembledPage | null>(null);
166
166
  const [err, setErr] = useState("");
167
- const [msg, setMsg] = useState("");
168
167
 
169
168
  const btBySlug = useMemo(() => new Map(blockTypes.map((b) => [b.slug, b])), [blockTypes]);
170
169
 
@@ -180,12 +179,16 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
180
179
  useEffect(() => { reload(); }, [reload]);
181
180
 
182
181
  const regions: RegionDefinition[] = ct?.regions ?? [];
183
- const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
182
+
183
+ const patchRegion = (region: string, fn: (list: RenderedBlock[]) => RenderedBlock[]) =>
184
+ setAssembled((prev) => (prev ? { ...prev, regions: { ...prev.regions, [region]: fn(prev.regions[region] ?? []) } } : prev));
184
185
 
185
186
  const addBlock = async (region: string, slug: string) => {
187
+ // Optimistic: show a placeholder immediately (no wait for the round trip), then
188
+ // reconcile with the persisted row addBlock echoes — or roll it back on failure.
189
+ const tempId = `tmp:${crypto.randomUUID()}`;
190
+ patchRegion(region, (list) => [...list, { id: tempId, block_id: tempId, block_type: slug, title: null, fields: {}, is_shared: false, pending: true }]);
186
191
  try {
187
- // addBlock echoes the created block + placement, so append it locally instead of a
188
- // full getContentType+getPage reload — one round trip instead of three.
189
192
  const { block, placement } = await api.call<{
190
193
  block: { id: string; title?: string | null; fields?: Record<string, unknown> | null };
191
194
  placement: { id: string; isShared?: boolean | number };
@@ -198,9 +201,9 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
198
201
  fields: block.fields ?? {},
199
202
  is_shared: Boolean(placement.isShared),
200
203
  };
201
- setAssembled((prev) => (prev ? { ...prev, regions: { ...prev.regions, [region]: [...(prev.regions[region] ?? []), rb] } } : prev));
202
- flash("block added");
204
+ patchRegion(region, (list) => list.map((b) => (b.id === tempId ? rb : b)));
203
205
  } catch (e) {
206
+ patchRegion(region, (list) => list.filter((b) => b.id !== tempId));
204
207
  setErr(errMsg(e));
205
208
  }
206
209
  };
@@ -268,7 +271,6 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
268
271
 
269
272
  <div className="overflow-auto py-1.5">
270
273
  {err ? <Banner>{err}</Banner> : null}
271
- {msg ? <Banner ok>{msg}</Banner> : null}
272
274
  {regions.map((r) => {
273
275
  const blocks = assembled?.regions[r.name] ?? [];
274
276
  const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
@@ -320,9 +322,9 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
320
322
  }
321
323
 
322
324
  // A single block, edited inline: the block IS its editor. Fields render in place (rich text
323
- // as the WYSIWYG, media as a thumbnail picker), and edits autosave debounced while typing,
324
- // flushed on blur and on unmount so there's no separate "save block" step and no full page
325
- // reload that would drop the caret. Collapse folds it to a one-line plain-text preview.
325
+ // as the WYSIWYG, media as a thumbnail picker). Edits are held locally and committed only on
326
+ // an explicit Save the header + footer show an "unsaved" state until you do, and nothing is
327
+ // written until you click Save. Collapse folds it to a one-line plain-text preview.
326
328
  function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError, dragging, isOver, onDragStartBlock, onDragOverBlock, onDropBlock, onDragEndBlock }: {
327
329
  api: Api;
328
330
  block: RenderedBlock;
@@ -343,45 +345,47 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
343
345
  const [fields, setFields] = useState<Record<string, unknown> | null>(null);
344
346
  const [collapsed, setCollapsed] = useState(false);
345
347
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
346
- const pending = useRef<Record<string, unknown> | null>(null);
347
- const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
348
+ const saved = useRef<string>(""); // JSON of the last-persisted fields — the dirty baseline
348
349
  const cardRef = useRef<HTMLDivElement>(null);
349
350
  const blockId = block.block_id;
350
351
  const placementId = block.id;
351
352
 
352
353
  // Load RAW fields (media as ids, richtext as HTML) so the value round-trips on save.
354
+ // A pending optimistic block has no persisted row yet — start empty and skip the fetch
355
+ // (its temp id would 404); when it reconciles to real ids the card remounts and fetches.
353
356
  useEffect(() => {
357
+ if (block.pending) { setFields({}); saved.current = "{}"; return; }
354
358
  let alive = true;
355
- api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => { if (alive) setFields(b?.fields ?? {}); }).catch((e) => onError(errMsg(e)));
359
+ api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => {
360
+ if (!alive) return;
361
+ const f = b?.fields ?? {};
362
+ setFields(f);
363
+ saved.current = JSON.stringify(f);
364
+ }).catch((e) => onError(errMsg(e)));
356
365
  return () => { alive = false; };
357
- }, [api, blockId, onError]);
366
+ }, [api, blockId, onError, block.pending]);
367
+
368
+ const dirty = fields != null && JSON.stringify(fields) !== saved.current;
358
369
 
359
- const flush = useCallback(async () => {
360
- if (timer.current) { clearTimeout(timer.current); timer.current = undefined; }
361
- const next = pending.current;
362
- if (!next) return;
363
- pending.current = null;
370
+ // Explicit save the ONLY thing that writes. No autosave, no save-on-blur, no save-on-unmount.
371
+ const save = useCallback(async () => {
372
+ if (block.pending || fields == null) return;
373
+ const snapshot = JSON.stringify(fields);
374
+ if (snapshot === saved.current) return;
364
375
  setSaveState("saving");
365
376
  try {
366
- await api.call("updateBlock", { blockId, fields: next });
367
- onPatch(placementId, next);
377
+ await api.call("updateBlock", { blockId, fields });
378
+ saved.current = snapshot;
379
+ onPatch(placementId, fields);
368
380
  setSaveState("saved");
369
- setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1200);
381
+ setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1500);
370
382
  } catch (e) {
371
383
  onError(errMsg(e));
372
384
  setSaveState("idle");
373
385
  }
374
- }, [api, blockId, placementId, onPatch, onError]);
386
+ }, [api, blockId, placementId, fields, onPatch, onError, block.pending]);
375
387
 
376
- // Flush a pending edit if the card unmounts (nav away, reorder remount) before the debounce.
377
- useEffect(() => () => { if (pending.current) void flush(); }, [flush]);
378
-
379
- const change = (next: Record<string, unknown>) => {
380
- setFields(next);
381
- pending.current = next;
382
- if (timer.current) clearTimeout(timer.current);
383
- timer.current = setTimeout(() => void flush(), 700);
384
- };
388
+ const change = (next: Record<string, unknown>) => setFields(next);
385
389
 
386
390
  const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
387
391
  const name = blockType?.name ?? block.block_type;
@@ -392,7 +396,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
392
396
  // image is set to the whole card so you drag a preview of the block, not just the grip.
393
397
  <div
394
398
  ref={cardRef}
395
- className={`mb-2.5 rounded-panel border bg-surface-card transition-colors ${isOver ? "border-brand-green" : "border-border"} ${dragging ? "opacity-40" : ""}`}
399
+ className={`mb-2.5 rounded-panel border bg-surface-card transition-colors ${isOver ? "border-brand-green" : "border-border"} ${dragging ? "opacity-40" : ""} ${block.pending ? "pointer-events-none opacity-60" : ""}`}
396
400
  onDragOver={(e) => { e.preventDefault(); onDragOverBlock(); }}
397
401
  onDrop={(e) => { e.preventDefault(); onDropBlock(); }}
398
402
  >
@@ -412,7 +416,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
412
416
  <button type="button" className="w-4 shrink-0 text-fg-subtle hover:text-fg" title={collapsed ? "Expand" : "Collapse"} onClick={() => setCollapsed((c) => !c)}>{collapsed ? "▸" : "▾"}</button>
413
417
  <span className="font-medium text-fg">{name}</span>
414
418
  {block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
415
- <span className="text-[11px] text-fg-subtle">{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : ""}</span>
419
+ <span className={`text-[11px] ${dirty && saveState !== "saving" ? "text-accent-strong" : "text-fg-subtle"}`}>{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : dirty ? "● unsaved" : ""}</span>
416
420
  <span className="flex-1" />
417
421
  <Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
418
422
  <Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
@@ -423,15 +427,25 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
423
427
  {fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
424
428
  </div>
425
429
  ) : (
426
- // onBlur bubbles from the inner inputs — leaving the block flushes any pending edit
427
- // immediately (flush() no-ops when nothing is pending, so tabbing between fields is free).
428
- <div className="border-t border-border px-3.5 py-3.5" onBlur={() => void flush()}>
430
+ <div className="border-t border-border px-3.5 py-3.5">
429
431
  {fields == null ? (
430
432
  <p className="text-sm text-fg-subtle">loading…</p>
431
433
  ) : schema.length === 0 ? (
432
434
  <p className="text-sm text-fg-subtle">This block has no editable fields.</p>
433
435
  ) : (
434
- <FieldForm schema={schema} value={fields} onChange={change} api={api} />
436
+ <>
437
+ <FieldForm schema={schema} value={fields} onChange={change} api={api} />
438
+ <div className="mt-3.5 flex items-center gap-2 border-t border-border pt-3">
439
+ <Button size="sm" onPress={() => void save()} isDisabled={!dirty || saveState === "saving" || block.pending}>
440
+ {saveState === "saving" ? "Saving…" : "Save"}
441
+ </Button>
442
+ {dirty ? (
443
+ <span className="text-[11px] text-accent-strong">Unsaved changes</span>
444
+ ) : saveState === "saved" ? (
445
+ <span className="text-[11px] text-fg-subtle">Saved ✓</span>
446
+ ) : null}
447
+ </div>
448
+ </>
435
449
  )}
436
450
  </div>
437
451
  )}
package/src/types.ts CHANGED
@@ -95,6 +95,7 @@ export interface RenderedBlock {
95
95
  title: string | null;
96
96
  fields: Record<string, unknown>;
97
97
  is_shared: boolean;
98
+ pending?: boolean; // optimistic placeholder — not yet persisted (temp ids, no getBlock)
98
99
  }
99
100
 
100
101
  export interface AssembledPage {