@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/dist/app.css +1 -1
- package/dist/index.html +1 -1
- package/dist/main.6dkmyshq.js +251 -0
- package/package.json +1 -1
- package/src/components.tsx +52 -38
- package/src/types.ts +1 -0
- package/dist/main.y75r3gyx.js +0 -251
package/package.json
CHANGED
package/src/components.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
324
|
-
//
|
|
325
|
-
//
|
|
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
|
|
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) => {
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
367
|
-
|
|
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)),
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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