@pramen/cms-editor 0.0.26 → 0.0.28
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.e0yd6v1m.js +251 -0
- package/package.json +1 -1
- package/src/components.tsx +206 -65
- package/dist/main.ywh6bxc0.js +0 -251
package/package.json
CHANGED
package/src/components.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the app context and wire URL params + navigation into these components.
|
|
4
4
|
|
|
5
5
|
import { Button, Input, Textarea } from "@podoba/react";
|
|
6
|
-
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
7
7
|
import { Api, ApiError } from "./api";
|
|
8
8
|
import { FieldForm } from "./fields";
|
|
9
9
|
import type { Config } from "./api";
|
|
@@ -163,7 +163,6 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
|
|
|
163
163
|
export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void }) {
|
|
164
164
|
const [ct, setCt] = useState<ContentType | null>(null);
|
|
165
165
|
const [assembled, setAssembled] = useState<AssembledPage | null>(null);
|
|
166
|
-
const [selected, setSelected] = useState<RenderedBlock | null>(null);
|
|
167
166
|
const [err, setErr] = useState("");
|
|
168
167
|
const [msg, setMsg] = useState("");
|
|
169
168
|
|
|
@@ -178,10 +177,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
178
177
|
setErr(errMsg(e));
|
|
179
178
|
}
|
|
180
179
|
}, [api, page.typeId, page.slug, page.locale]);
|
|
181
|
-
useEffect(() => {
|
|
182
|
-
reload();
|
|
183
|
-
setSelected(null);
|
|
184
|
-
}, [reload]);
|
|
180
|
+
useEffect(() => { reload(); }, [reload]);
|
|
185
181
|
|
|
186
182
|
const regions: RegionDefinition[] = ct?.regions ?? [];
|
|
187
183
|
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
|
|
@@ -198,7 +194,6 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
198
194
|
const removeBlock = async (b: RenderedBlock) => {
|
|
199
195
|
try {
|
|
200
196
|
await api.call("removeBlock", { pageBlockId: b.id });
|
|
201
|
-
if (selected?.id === b.id) setSelected(null);
|
|
202
197
|
await reload();
|
|
203
198
|
} catch (e) {
|
|
204
199
|
setErr(errMsg(e));
|
|
@@ -213,6 +208,33 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
213
208
|
}
|
|
214
209
|
};
|
|
215
210
|
|
|
211
|
+
// Patch a block's raw fields into local state after an inline save — keeps the collapsed
|
|
212
|
+
// preview fresh without a full reload (which would remount every editor + lose caret/focus).
|
|
213
|
+
const patchBlockFields = useCallback((placementId: string, fields: Record<string, unknown>) => {
|
|
214
|
+
setAssembled((prev) => {
|
|
215
|
+
if (!prev) return prev;
|
|
216
|
+
const next: Record<string, RenderedBlock[]> = {};
|
|
217
|
+
for (const [name, list] of Object.entries(prev.regions)) next[name] = list.map((b) => (b.id === placementId ? { ...b, fields } : b));
|
|
218
|
+
return { ...prev, regions: next };
|
|
219
|
+
});
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
// Drag-to-reorder within a region. `from`/`over` are indices in the region's block list;
|
|
223
|
+
// hovering updates `over` live (for the drop-target highlight), and the drop commits the
|
|
224
|
+
// new order via reorderRegion. Dragging is confined to the region it started in.
|
|
225
|
+
const [drag, setDrag] = useState<{ region: string; from: number; over: number } | null>(null);
|
|
226
|
+
const beginDrag = (region: string, i: number) => setDrag({ region, from: i, over: i });
|
|
227
|
+
const hoverDrag = (region: string, i: number) => setDrag((d) => (d && d.region === region && d.over !== i ? { ...d, over: i } : d));
|
|
228
|
+
const cancelDrag = () => setDrag(null);
|
|
229
|
+
const dropDrag = (region: string) => {
|
|
230
|
+
setDrag(null);
|
|
231
|
+
if (!drag || drag.region !== region || drag.from === drag.over) return;
|
|
232
|
+
const ids = (assembled?.regions[region] ?? []).map((b) => b.id);
|
|
233
|
+
const [moved] = ids.splice(drag.from, 1);
|
|
234
|
+
ids.splice(drag.over, 0, moved);
|
|
235
|
+
reorder(region, ids);
|
|
236
|
+
};
|
|
237
|
+
|
|
216
238
|
return (
|
|
217
239
|
<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">
|
|
218
240
|
<div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
|
|
@@ -238,28 +260,30 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
238
260
|
const blocks = assembled?.regions[r.name] ?? [];
|
|
239
261
|
const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
|
|
240
262
|
return (
|
|
241
|
-
<div className="mb-
|
|
242
|
-
<h3 className="m-0 mb-3
|
|
243
|
-
|
|
244
|
-
{r.allowedTypes ? <span className="text-[11px] font-normal text-fg-subtle">only: {r.allowedTypes.join(", ")}</span> : null}
|
|
245
|
-
</h3>
|
|
263
|
+
<div className="mb-8" key={r.name}>
|
|
264
|
+
<h3 className="m-0 mb-3 text-[22px] font-normal text-fg">{r.label ?? r.name}</h3>
|
|
265
|
+
{blocks.length === 0 ? <p className="mb-3 text-sm text-fg-subtle">No blocks yet — add one below.</p> : null}
|
|
246
266
|
{blocks.map((b, i) => (
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
267
|
+
<BlockCard
|
|
268
|
+
key={b.id}
|
|
269
|
+
api={api}
|
|
270
|
+
block={b}
|
|
271
|
+
blockType={btBySlug.get(b.block_type)}
|
|
272
|
+
isFirst={i === 0}
|
|
273
|
+
isLast={i === blocks.length - 1}
|
|
274
|
+
onMove={(d) => reorderMove(blocks, r.name, i, d, reorder)}
|
|
275
|
+
onRemove={() => removeBlock(b)}
|
|
276
|
+
onPatch={patchBlockFields}
|
|
277
|
+
onError={setErr}
|
|
278
|
+
dragging={drag?.region === r.name && drag.from === i}
|
|
279
|
+
isOver={!!drag && drag.region === r.name && drag.over === i && drag.from !== i}
|
|
280
|
+
onDragStartBlock={() => beginDrag(r.name, i)}
|
|
281
|
+
onDragOverBlock={() => hoverDrag(r.name, i)}
|
|
282
|
+
onDropBlock={() => dropDrag(r.name)}
|
|
283
|
+
onDragEndBlock={cancelDrag}
|
|
284
|
+
/>
|
|
257
285
|
))}
|
|
258
|
-
<
|
|
259
|
-
{allowed.map((slug) => (
|
|
260
|
-
<Button key={slug} variant="secondary" size="sm" onPress={() => addBlock(r.name, slug)}>+ {btBySlug.get(slug)?.name ?? slug}</Button>
|
|
261
|
-
))}
|
|
262
|
-
</div>
|
|
286
|
+
<AddBlock allowed={allowed} btBySlug={btBySlug} onAdd={(slug) => addBlock(r.name, slug)} />
|
|
263
287
|
</div>
|
|
264
288
|
);
|
|
265
289
|
})}
|
|
@@ -267,55 +291,157 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
|
|
|
267
291
|
</div>
|
|
268
292
|
|
|
269
293
|
<div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
{tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
|
|
281
|
-
{tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
|
|
282
|
-
{tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
283
|
-
{tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
|
|
284
|
-
</>
|
|
285
|
-
)}
|
|
294
|
+
<div className="mb-3 flex gap-1">
|
|
295
|
+
{INSPECTOR_TABS.map((t) => (
|
|
296
|
+
<Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
299
|
+
{tab === "settings" ? <Settings page={page} /> : null}
|
|
300
|
+
{tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
|
|
301
|
+
{tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
|
|
302
|
+
{tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
303
|
+
{tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
|
|
286
304
|
</div>
|
|
287
305
|
</div>
|
|
288
306
|
);
|
|
289
307
|
}
|
|
290
308
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
309
|
+
// A single block, edited inline: the block IS its editor. Fields render in place (rich text
|
|
310
|
+
// as the WYSIWYG, media as a thumbnail picker), and edits autosave — debounced while typing,
|
|
311
|
+
// flushed on blur and on unmount — so there's no separate "save block" step and no full page
|
|
312
|
+
// reload that would drop the caret. Collapse folds it to a one-line plain-text preview.
|
|
313
|
+
function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, onPatch, onError, dragging, isOver, onDragStartBlock, onDragOverBlock, onDropBlock, onDragEndBlock }: {
|
|
314
|
+
api: Api;
|
|
315
|
+
block: RenderedBlock;
|
|
316
|
+
blockType: BlockType | undefined;
|
|
317
|
+
isFirst: boolean;
|
|
318
|
+
isLast: boolean;
|
|
319
|
+
onMove: (dir: number) => void;
|
|
320
|
+
onRemove: () => void;
|
|
321
|
+
onPatch: (placementId: string, fields: Record<string, unknown>) => void;
|
|
322
|
+
onError: (s: string) => void;
|
|
323
|
+
dragging: boolean;
|
|
324
|
+
isOver: boolean;
|
|
325
|
+
onDragStartBlock: () => void;
|
|
326
|
+
onDragOverBlock: () => void;
|
|
327
|
+
onDropBlock: () => void;
|
|
328
|
+
onDragEndBlock: () => void;
|
|
329
|
+
}) {
|
|
330
|
+
const [fields, setFields] = useState<Record<string, unknown> | null>(null);
|
|
331
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
332
|
+
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
|
|
333
|
+
const pending = useRef<Record<string, unknown> | null>(null);
|
|
334
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
335
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
336
|
+
const blockId = block.block_id;
|
|
337
|
+
const placementId = block.id;
|
|
338
|
+
|
|
339
|
+
// Load RAW fields (media as ids, richtext as HTML) so the value round-trips on save.
|
|
294
340
|
useEffect(() => {
|
|
295
|
-
|
|
296
|
-
api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
341
|
+
let alive = true;
|
|
342
|
+
api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => { if (alive) setFields(b?.fields ?? {}); }).catch((e) => onError(errMsg(e)));
|
|
343
|
+
return () => { alive = false; };
|
|
344
|
+
}, [api, blockId, onError]);
|
|
345
|
+
|
|
346
|
+
const flush = useCallback(async () => {
|
|
347
|
+
if (timer.current) { clearTimeout(timer.current); timer.current = undefined; }
|
|
348
|
+
const next = pending.current;
|
|
349
|
+
if (!next) return;
|
|
350
|
+
pending.current = null;
|
|
351
|
+
setSaveState("saving");
|
|
301
352
|
try {
|
|
302
|
-
await api.call("updateBlock", { blockId
|
|
303
|
-
|
|
353
|
+
await api.call("updateBlock", { blockId, fields: next });
|
|
354
|
+
onPatch(placementId, next);
|
|
355
|
+
setSaveState("saved");
|
|
356
|
+
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1200);
|
|
304
357
|
} catch (e) {
|
|
305
358
|
onError(errMsg(e));
|
|
306
|
-
|
|
307
|
-
setBusy(false);
|
|
359
|
+
setSaveState("idle");
|
|
308
360
|
}
|
|
361
|
+
}, [api, blockId, placementId, onPatch, onError]);
|
|
362
|
+
|
|
363
|
+
// Flush a pending edit if the card unmounts (nav away, reorder remount) before the debounce.
|
|
364
|
+
useEffect(() => () => { if (pending.current) void flush(); }, [flush]);
|
|
365
|
+
|
|
366
|
+
const change = (next: Record<string, unknown>) => {
|
|
367
|
+
setFields(next);
|
|
368
|
+
pending.current = next;
|
|
369
|
+
if (timer.current) clearTimeout(timer.current);
|
|
370
|
+
timer.current = setTimeout(() => void flush(), 700);
|
|
309
371
|
};
|
|
372
|
+
|
|
373
|
+
const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
|
|
374
|
+
const name = blockType?.name ?? block.block_type;
|
|
375
|
+
|
|
310
376
|
return (
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
377
|
+
// The card is the drop target; the ⠿ handle is the only draggable element, so
|
|
378
|
+
// dragging never fights the inline contentEditable text selection. The handle's drag
|
|
379
|
+
// image is set to the whole card so you drag a preview of the block, not just the grip.
|
|
380
|
+
<div
|
|
381
|
+
ref={cardRef}
|
|
382
|
+
className={`mb-2.5 rounded-panel border bg-surface-card transition-colors ${isOver ? "border-brand-green" : "border-border"} ${dragging ? "opacity-40" : ""}`}
|
|
383
|
+
onDragOver={(e) => { e.preventDefault(); onDragOverBlock(); }}
|
|
384
|
+
onDrop={(e) => { e.preventDefault(); onDropBlock(); }}
|
|
385
|
+
>
|
|
386
|
+
<div className="flex items-center gap-2.5 px-3.5 py-2.5">
|
|
387
|
+
<span
|
|
388
|
+
draggable
|
|
389
|
+
onDragStart={(e) => {
|
|
390
|
+
if (cardRef.current) e.dataTransfer.setDragImage(cardRef.current, 12, 12);
|
|
391
|
+
e.dataTransfer.effectAllowed = "move";
|
|
392
|
+
e.dataTransfer.setData("text/plain", "");
|
|
393
|
+
onDragStartBlock();
|
|
394
|
+
}}
|
|
395
|
+
onDragEnd={onDragEndBlock}
|
|
396
|
+
className="shrink-0 cursor-grab select-none px-0.5 text-fg-subtle hover:text-fg active:cursor-grabbing"
|
|
397
|
+
title="Drag to reorder"
|
|
398
|
+
>⠿</span>
|
|
399
|
+
<button type="button" className="w-4 shrink-0 text-fg-subtle hover:text-fg" title={collapsed ? "Expand" : "Collapse"} onClick={() => setCollapsed((c) => !c)}>{collapsed ? "▸" : "▾"}</button>
|
|
400
|
+
<span className="font-medium text-fg">{name}</span>
|
|
401
|
+
{block.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
|
|
402
|
+
<span className="text-[11px] text-fg-subtle">{saveState === "saving" ? "saving…" : saveState === "saved" ? "saved ✓" : ""}</span>
|
|
314
403
|
<span className="flex-1" />
|
|
315
|
-
<Button variant="ghost" size="sm" onPress={
|
|
404
|
+
<Button variant="ghost" size="sm" isDisabled={isFirst} onPress={() => onMove(-1)}>↑</Button>
|
|
405
|
+
<Button variant="ghost" size="sm" isDisabled={isLast} onPress={() => onMove(1)}>↓</Button>
|
|
406
|
+
<Button variant="ghost" size="sm" className="text-danger" onPress={onRemove}>✕</Button>
|
|
407
|
+
</div>
|
|
408
|
+
{collapsed ? (
|
|
409
|
+
<div className="cursor-pointer truncate px-3.5 pb-3 text-sm text-fg-subtle" onClick={() => setCollapsed(false)}>
|
|
410
|
+
{fields == null ? "…" : blockPreview(fields) || <span className="italic">empty</span>}
|
|
411
|
+
</div>
|
|
412
|
+
) : (
|
|
413
|
+
// onBlur bubbles from the inner inputs — leaving the block flushes any pending edit
|
|
414
|
+
// immediately (flush() no-ops when nothing is pending, so tabbing between fields is free).
|
|
415
|
+
<div className="border-t border-border px-3.5 py-3.5" onBlur={() => void flush()}>
|
|
416
|
+
{fields == null ? (
|
|
417
|
+
<p className="text-sm text-fg-subtle">loading…</p>
|
|
418
|
+
) : schema.length === 0 ? (
|
|
419
|
+
<p className="text-sm text-fg-subtle">This block has no editable fields.</p>
|
|
420
|
+
) : (
|
|
421
|
+
<FieldForm schema={schema} value={fields} onChange={change} api={api} />
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// "+ Add block" reveals a compact picker of the region's allowed types (by friendly name),
|
|
430
|
+
// replacing the always-on row of every type as a wall of buttons.
|
|
431
|
+
function AddBlock({ allowed, btBySlug, onAdd }: { allowed: string[]; btBySlug: Map<string, BlockType>; onAdd: (slug: string) => void }) {
|
|
432
|
+
const [open, setOpen] = useState(false);
|
|
433
|
+
if (!open) return <Button variant="secondary" size="sm" onPress={() => setOpen(true)}>+ Add block</Button>;
|
|
434
|
+
return (
|
|
435
|
+
<div className="rounded-panel border border-border bg-surface-card p-2.5">
|
|
436
|
+
<div className="mb-1.5 px-1 text-[11px] text-fg-subtle">Add a block</div>
|
|
437
|
+
<div className="flex flex-wrap gap-1.5">
|
|
438
|
+
{allowed.map((slug) => (
|
|
439
|
+
<Button key={slug} variant="ghost" size="sm" onPress={() => { onAdd(slug); setOpen(false); }}>{btBySlug.get(slug)?.name ?? slug}</Button>
|
|
440
|
+
))}
|
|
441
|
+
</div>
|
|
442
|
+
<div className="mt-1.5 text-right">
|
|
443
|
+
<Button variant="ghost" size="sm" className="text-fg-subtle" onPress={() => setOpen(false)}>cancel</Button>
|
|
316
444
|
</div>
|
|
317
|
-
{schema.length === 0 ? <p className="text-fg-subtle">This block type has no fields.</p> : <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />}
|
|
318
|
-
<Button className="mt-3 w-full" onPress={save} isDisabled={busy}>Save block</Button>
|
|
319
445
|
</div>
|
|
320
446
|
);
|
|
321
447
|
}
|
|
@@ -856,9 +982,24 @@ export function errMsg(e: unknown): string {
|
|
|
856
982
|
function slugify(s: string): string {
|
|
857
983
|
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
858
984
|
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
985
|
+
/** Strip HTML tags + decode the few entities the WYSIWYG emits, for a clean text preview —
|
|
986
|
+
* so a collapsed rich_text block reads "Test Toakdopwad" instead of "<b>Test</b> …". */
|
|
987
|
+
function plainText(html: string): string {
|
|
988
|
+
return html
|
|
989
|
+
.replace(/<[^>]*>/g, " ")
|
|
990
|
+
.replace(/ /g, " ")
|
|
991
|
+
.replace(/&/g, "&")
|
|
992
|
+
.replace(/</g, "<")
|
|
993
|
+
.replace(/>/g, ">")
|
|
994
|
+
.replace(/\s+/g, " ")
|
|
995
|
+
.trim();
|
|
996
|
+
}
|
|
997
|
+
/** One-line preview for a collapsed block: the first non-empty string field, tags stripped. */
|
|
998
|
+
function blockPreview(fields: Record<string, unknown>): string {
|
|
999
|
+
const first = Object.values(fields).find((v) => typeof v === "string" && v.trim());
|
|
1000
|
+
if (typeof first !== "string") return "";
|
|
1001
|
+
const text = plainText(first);
|
|
1002
|
+
return text.length > 90 ? text.slice(0, 90) + "…" : text;
|
|
862
1003
|
}
|
|
863
1004
|
function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: number, reorder: (region: string, order: string[]) => void) {
|
|
864
1005
|
const j = i + d;
|