@pramen/cms-editor 0.0.30 → 0.0.32

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.32",
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": {
@@ -165,6 +165,23 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
165
165
  const [assembled, setAssembled] = useState<AssembledPage | null>(null);
166
166
  const [err, setErr] = useState("");
167
167
 
168
+ // Unsaved-changes guard. Each BlockCard reports its dirty state up; while any block has
169
+ // unsaved edits, warn before leaving the editor (← all pages) and before a browser unload
170
+ // (refresh / close / navigating off-site). Reorder/add/remove keep block state — React
171
+ // reuses BlockCard instances by key — so those don't lose edits and need no guard; only
172
+ // unmounting the whole editor (leaving) or a page unload discards the local edits.
173
+ const dirtyRef = useRef<Set<string>>(new Set());
174
+ const [dirtyCount, setDirtyCount] = useState(0);
175
+ const reportDirty = useCallback((id: string, isDirty: boolean) => {
176
+ const s = dirtyRef.current;
177
+ if (isDirty ? s.has(id) : !s.has(id)) return;
178
+ if (isDirty) s.add(id);
179
+ else s.delete(id);
180
+ setDirtyCount(s.size);
181
+ }, []);
182
+ const confirmLeave = () =>
183
+ dirtyRef.current.size === 0 || window.confirm(`You have unsaved changes in ${dirtyRef.current.size} block${dirtyRef.current.size === 1 ? "" : "s"}. Leave without saving?`);
184
+
168
185
  const btBySlug = useMemo(() => new Map(blockTypes.map((b) => [b.slug, b])), [blockTypes]);
169
186
 
170
187
  const reload = useCallback(async () => {
@@ -178,6 +195,14 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
178
195
  }, [api, page.typeId, page.slug, page.locale]);
179
196
  useEffect(() => { reload(); }, [reload]);
180
197
 
198
+ // Native prompt on refresh/close/off-site nav while there are unsaved edits.
199
+ useEffect(() => {
200
+ if (dirtyCount === 0) return;
201
+ const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = ""; };
202
+ window.addEventListener("beforeunload", onBeforeUnload);
203
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
204
+ }, [dirtyCount]);
205
+
181
206
  const regions: RegionDefinition[] = ct?.regions ?? [];
182
207
 
183
208
  const patchRegion = (region: string, fn: (list: RenderedBlock[]) => RenderedBlock[]) =>
@@ -254,7 +279,8 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
254
279
  return (
255
280
  <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">
256
281
  <div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
257
- <Button variant="ghost" size="sm" onPress={onBack}>← all pages</Button>
282
+ <Button variant="ghost" size="sm" onPress={() => { if (confirmLeave()) onBack(); }}>← all pages</Button>
283
+ {dirtyCount > 0 ? <p className="mt-2 text-[11px] text-accent-strong">● {dirtyCount} unsaved block{dirtyCount === 1 ? "" : "s"}</p> : null}
258
284
  <Section>Regions</Section>
259
285
  {regions.map((r) => (
260
286
  <div key={r.name} className={`${ROW} mb-2`}>
@@ -289,6 +315,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
289
315
  onMove={(d) => reorderMove(blocks, r.name, i, d, reorder)}
290
316
  onRemove={() => removeBlock(b)}
291
317
  onPatch={patchBlockFields}
318
+ onDirtyChange={reportDirty}
292
319
  onError={setErr}
293
320
  dragging={drag?.region === r.name && drag.from === i}
294
321
  isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
@@ -322,10 +349,10 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
322
349
  }
323
350
 
324
351
  // 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.
328
- function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError, dragging, isOver, onDragStartBlock, onDragOverBlock, onDropBlock, onDragEndBlock }: {
352
+ // as the WYSIWYG, media as a thumbnail picker). Edits are held locally and committed only on
353
+ // an explicit Save the header + footer show an "unsaved" state until you do, and nothing is
354
+ // written until you click Save. Collapse folds it to a one-line plain-text preview.
355
+ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onDirtyChange, onError, dragging, isOver, onDragStartBlock, onDragOverBlock, onDropBlock, onDragEndBlock }: {
329
356
  api: Api;
330
357
  block: RenderedBlock;
331
358
  blockType: BlockType | undefined;
@@ -334,6 +361,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
334
361
  onMove: (dir: number) => void;
335
362
  onRemove: () => void;
336
363
  onPatch: (placementId: string, fields: Record<string, unknown>) => void;
364
+ onDirtyChange: (placementId: string, dirty: boolean) => void;
337
365
  onError: (s: string) => void;
338
366
  dragging: boolean;
339
367
  isOver: boolean;
@@ -345,8 +373,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
345
373
  const [fields, setFields] = useState<Record<string, unknown> | null>(null);
346
374
  const [collapsed, setCollapsed] = useState(false);
347
375
  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);
376
+ const saved = useRef<string>(""); // JSON of the last-persisted fields — the dirty baseline
350
377
  const cardRef = useRef<HTMLDivElement>(null);
351
378
  const blockId = block.block_id;
352
379
  const placementId = block.id;
@@ -355,39 +382,43 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
355
382
  // A pending optimistic block has no persisted row yet — start empty and skip the fetch
356
383
  // (its temp id would 404); when it reconciles to real ids the card remounts and fetches.
357
384
  useEffect(() => {
358
- if (block.pending) { setFields({}); return; }
385
+ if (block.pending) { setFields({}); saved.current = "{}"; return; }
359
386
  let alive = true;
360
- api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => { if (alive) setFields(b?.fields ?? {}); }).catch((e) => onError(errMsg(e)));
387
+ api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => {
388
+ if (!alive) return;
389
+ const f = b?.fields ?? {};
390
+ setFields(f);
391
+ saved.current = JSON.stringify(f);
392
+ }).catch((e) => onError(errMsg(e)));
361
393
  return () => { alive = false; };
362
394
  }, [api, blockId, onError, block.pending]);
363
395
 
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;
396
+ const dirty = fields != null && JSON.stringify(fields) !== saved.current;
397
+
398
+ // Explicit save the ONLY thing that writes. No autosave, no save-on-blur, no save-on-unmount.
399
+ const save = useCallback(async () => {
400
+ if (block.pending || fields == null) return;
401
+ const snapshot = JSON.stringify(fields);
402
+ if (snapshot === saved.current) return;
370
403
  setSaveState("saving");
371
404
  try {
372
- await api.call("updateBlock", { blockId, fields: next });
373
- onPatch(placementId, next);
405
+ await api.call("updateBlock", { blockId, fields });
406
+ saved.current = snapshot;
407
+ onPatch(placementId, fields);
374
408
  setSaveState("saved");
375
- setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1200);
409
+ setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1500);
376
410
  } catch (e) {
377
411
  onError(errMsg(e));
378
412
  setSaveState("idle");
379
413
  }
380
- }, [api, blockId, placementId, onPatch, onError, block.pending]);
414
+ }, [api, blockId, placementId, fields, onPatch, onError, block.pending]);
381
415
 
382
- // Flush a pending edit if the card unmounts (nav away, reorder remount) before the debounce.
383
- useEffect(() => () => { if (pending.current) void flush(); }, [flush]);
416
+ const change = (next: Record<string, unknown>) => setFields(next);
384
417
 
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
- };
418
+ // Report dirty state up (for the editor's leave/unload guard); clear it on unmount so a
419
+ // removed block never leaves a stale "unsaved" flag behind.
420
+ useEffect(() => { onDirtyChange(placementId, dirty); }, [dirty, placementId, onDirtyChange]);
421
+ useEffect(() => () => { onDirtyChange(placementId, false); }, [placementId, onDirtyChange]);
391
422
 
392
423
  const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
393
424
  const name = blockType?.name ?? block.block_type;
@@ -418,7 +449,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
418
449
  <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
450
  <span className="font-medium text-fg">{name}</span>
420
451
  {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>
452
+ <span className={`text-[11px] ${dirty && saveState !== "saving" ? "text-accent-strong" : "text-fg-subtle"}`}>{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : dirty ? "● unsaved" : ""}</span>
422
453
  <span className="flex-1" />
423
454
  <Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
424
455
  <Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
@@ -429,15 +460,25 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
429
460
  {fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
430
461
  </div>
431
462
  ) : (
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()}>
463
+ <div className="border-t border-border px-3.5 py-3.5">
435
464
  {fields == null ? (
436
465
  <p className="text-sm text-fg-subtle">loading…</p>
437
466
  ) : schema.length === 0 ? (
438
467
  <p className="text-sm text-fg-subtle">This block has no editable fields.</p>
439
468
  ) : (
440
- <FieldForm schema={schema} value={fields} onChange={change} api={api} />
469
+ <>
470
+ <FieldForm schema={schema} value={fields} onChange={change} api={api} />
471
+ <div className="mt-3.5 flex items-center gap-2 border-t border-border pt-3">
472
+ <Button size="sm" onPress={() => void save()} isDisabled={!dirty || saveState === "saving" || block.pending}>
473
+ {saveState === "saving" ? "Saving…" : "Save"}
474
+ </Button>
475
+ {dirty ? (
476
+ <span className="text-[11px] text-accent-strong">Unsaved changes</span>
477
+ ) : saveState === "saved" ? (
478
+ <span className="text-[11px] text-fg-subtle">Saved ✓</span>
479
+ ) : null}
480
+ </div>
481
+ </>
441
482
  )}
442
483
  </div>
443
484
  )}