@pramen/cms-editor 0.0.30 → 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.30",
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": {
@@ -322,9 +322,9 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
322
322
  }
323
323
 
324
324
  // A single block, edited inline: the block IS its editor. Fields render in place (rich text
325
- // as the WYSIWYG, media as a thumbnail picker), and edits autosave debounced while typing,
326
- // flushed on blur and on unmount so there's no separate "save block" step and no full page
327
- // 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.
328
328
  function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError, dragging, isOver, onDragStartBlock, onDragOverBlock, onDropBlock, onDragEndBlock }: {
329
329
  api: Api;
330
330
  block: RenderedBlock;
@@ -345,8 +345,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
345
345
  const [fields, setFields] = useState<Record<string, unknown> | null>(null);
346
346
  const [collapsed, setCollapsed] = useState(false);
347
347
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
348
- const pending = useRef<Record<string, unknown> | null>(null);
349
- const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
348
+ const saved = useRef<string>(""); // JSON of the last-persisted fields — the dirty baseline
350
349
  const cardRef = useRef<HTMLDivElement>(null);
351
350
  const blockId = block.block_id;
352
351
  const placementId = block.id;
@@ -355,39 +354,38 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
355
354
  // A pending optimistic block has no persisted row yet — start empty and skip the fetch
356
355
  // (its temp id would 404); when it reconciles to real ids the card remounts and fetches.
357
356
  useEffect(() => {
358
- if (block.pending) { setFields({}); return; }
357
+ if (block.pending) { setFields({}); saved.current = "{}"; return; }
359
358
  let alive = true;
360
- 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)));
361
365
  return () => { alive = false; };
362
366
  }, [api, blockId, onError, block.pending]);
363
367
 
364
- const flush = useCallback(async () => {
365
- if (block.pending) return; // don't write against a placeholder's temp id
366
- if (timer.current) { clearTimeout(timer.current); timer.current = undefined; }
367
- const next = pending.current;
368
- if (!next) return;
369
- pending.current = null;
368
+ const dirty = fields != null && JSON.stringify(fields) !== saved.current;
369
+
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;
370
375
  setSaveState("saving");
371
376
  try {
372
- await api.call("updateBlock", { blockId, fields: next });
373
- onPatch(placementId, next);
377
+ await api.call("updateBlock", { blockId, fields });
378
+ saved.current = snapshot;
379
+ onPatch(placementId, fields);
374
380
  setSaveState("saved");
375
- setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1200);
381
+ setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1500);
376
382
  } catch (e) {
377
383
  onError(errMsg(e));
378
384
  setSaveState("idle");
379
385
  }
380
- }, [api, blockId, placementId, onPatch, onError, block.pending]);
381
-
382
- // Flush a pending edit if the card unmounts (nav away, reorder remount) before the debounce.
383
- useEffect(() => () => { if (pending.current) void flush(); }, [flush]);
386
+ }, [api, blockId, placementId, fields, onPatch, onError, block.pending]);
384
387
 
385
- const change = (next: Record<string, unknown>) => {
386
- setFields(next);
387
- pending.current = next;
388
- if (timer.current) clearTimeout(timer.current);
389
- timer.current = setTimeout(() => void flush(), 700);
390
- };
388
+ const change = (next: Record<string, unknown>) => setFields(next);
391
389
 
392
390
  const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
393
391
  const name = blockType?.name ?? block.block_type;
@@ -418,7 +416,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
418
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>
419
417
  <span className="font-medium text-fg">{name}</span>
420
418
  {block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
421
- <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>
422
420
  <span className="flex-1" />
423
421
  <Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
424
422
  <Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
@@ -429,15 +427,25 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
429
427
  {fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
430
428
  </div>
431
429
  ) : (
432
- // onBlur bubbles from the inner inputs — leaving the block flushes any pending edit
433
- // immediately (flush() no-ops when nothing is pending, so tabbing between fields is free).
434
- <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">
435
431
  {fields == null ? (
436
432
  <p className="text-sm text-fg-subtle">loading…</p>
437
433
  ) : schema.length === 0 ? (
438
434
  <p className="text-sm text-fg-subtle">This block has no editable fields.</p>
439
435
  ) : (
440
- <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
+ </>
441
449
  )}
442
450
  </div>
443
451
  )}