@pramen/cms-editor 0.0.31 → 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.31",
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}
@@ -325,7 +352,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
325
352
  // as the WYSIWYG, media as a thumbnail picker). Edits are held locally and committed only on
326
353
  // an explicit Save — the header + footer show an "unsaved" state until you do, and nothing is
327
354
  // written until you click Save. 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 }: {
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;
@@ -387,6 +415,11 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
387
415
 
388
416
  const change = (next: Record<string, unknown>) => setFields(next);
389
417
 
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]);
422
+
390
423
  const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
391
424
  const name = blockType?.name ?? block.block_type;
392
425