@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/dist/app.css +1 -1
- package/dist/index.html +1 -1
- package/dist/{main.wjbaszqq.js → main.kr19xkv0.js} +54 -54
- package/package.json +1 -1
- package/src/components.tsx +73 -32
package/package.json
CHANGED
package/src/components.tsx
CHANGED
|
@@ -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)
|
|
326
|
-
//
|
|
327
|
-
//
|
|
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
|
|
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) => {
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (
|
|
369
|
-
|
|
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
|
|
373
|
-
|
|
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)),
|
|
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
|
-
|
|
383
|
-
useEffect(() => () => { if (pending.current) void flush(); }, [flush]);
|
|
416
|
+
const change = (next: Record<string, unknown>) => setFields(next);
|
|
384
417
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
)}
|