@pramen/cms-editor 0.0.49 → 0.0.50

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.49",
4
- "description": "Visual block/page editor for @pramen/cms a standalone React SPA that talks to the CMS handlers over HTTP.",
3
+ "version": "0.0.50",
4
+ "description": "Visual block/page editor for @pramen/cms \u2014 a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -11,7 +11,10 @@
11
11
  "homepage": "https://github.com/netvarec/pramen#readme",
12
12
  "bugs": "https://github.com/netvarec/pramen/issues",
13
13
  "type": "module",
14
- "files": ["dist", "src"],
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
15
18
  "scripts": {
16
19
  "build": "bun run scripts/build.ts",
17
20
  "dev": "bun run scripts/build.ts --watch",
@@ -20,16 +23,17 @@
20
23
  },
21
24
  "dependencies": {
22
25
  "@buzola/router": "^0.0.12",
23
- "@podoba/react": "^0.0.32",
24
- "@podoba/tokens": "^0.0.32",
25
- "@podoba/tailwind": "^0.0.32",
26
- "@tiptap/react": "^3.27.4",
27
- "@tiptap/pm": "^3.27.4",
28
- "@tiptap/starter-kit": "^3.27.4",
29
- "@tiptap/extension-placeholder": "^3.27.4",
26
+ "@podoba/react": "^0.0.34",
27
+ "@podoba/tailwind": "^0.0.34",
28
+ "@podoba/tokens": "^0.0.34",
29
+ "@tiptap/core": "^3.27.4",
30
30
  "@tiptap/extension-highlight": "^3.27.4",
31
- "@tiptap/extension-task-list": "^3.27.4",
31
+ "@tiptap/extension-placeholder": "^3.27.4",
32
32
  "@tiptap/extension-task-item": "^3.27.4",
33
+ "@tiptap/extension-task-list": "^3.27.4",
34
+ "@tiptap/pm": "^3.27.4",
35
+ "@tiptap/react": "^3.27.4",
36
+ "@tiptap/starter-kit": "^3.27.4",
33
37
  "react": "^19.0.0",
34
38
  "react-dom": "^19.0.0"
35
39
  },
package/src/api.ts CHANGED
@@ -129,6 +129,12 @@ export class Api {
129
129
  getMedia = (id: string) => this.call<Media | null>("getMedia", { id });
130
130
  updateMedia = (id: string, alt: string | null) => this.call<Media>("updateMedia", { id, alt });
131
131
  deleteMedia = (id: string) => this.call<{ ok: true }>("deleteMedia", { id });
132
+ // Trash is not a UI nicety here: deleteMedia no longer removes the R2 object, so without
133
+ // a reachable purge a file can be "deleted" in the library and still be served on the
134
+ // live site — the case a takedown request actually needs.
135
+ listTrash = (limit = 50) => this.call<{ pages: Page[]; media: Media[] }>("listTrash", { limit });
136
+ restoreMedia = (id: string) => this.call<{ ok: true }>("restoreMedia", { id });
137
+ purgeMedia = (id: string) => this.call<{ ok: true }>("purgeMedia", { id });
132
138
 
133
139
  /** Full upload flow: sign → PUT the bytes → persist a `cms_media` row. Returns the row. */
134
140
  async uploadMedia(file: File): Promise<Media> {
@@ -5,10 +5,11 @@
5
5
  import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Textarea } from "@podoba/react";
6
6
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
7
  import { Api, ApiError } from "./api";
8
- import { FieldForm, slugify } from "./fields";
8
+ import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput } from "./fields";
9
9
  import type { Config } from "./api";
10
10
  import type { Me } from "./app-context";
11
- import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
11
+ import { isRichTextDoc, richTextToPlainText } from "./rich-text";
12
+ import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
12
13
 
13
14
  export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
14
15
  export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
@@ -186,11 +187,16 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
186
187
  // collections, zero per-collection code. Rows are addressed by `def.idField` (the entity's
187
188
  // PK column, defaults "id"); the server resolves the real PK from the value.
188
189
 
189
- /** Render a list-cell value as a short string (objects/arrays are summarized, not dumped). */
190
+ /** Render a list-cell value as a short string (objects/arrays are summarized, not dumped).
191
+ * A `richtext` column is a document tree, so flatten it to words rather than showing "—". */
190
192
  function cellText(v: unknown): string {
191
193
  if (v == null) return "";
192
194
  if (typeof v === "boolean") return v ? "yes" : "no";
193
195
  if (Array.isArray(v)) return v.length === 1 ? "1 item" : `${v.length} items`;
196
+ if (isRichTextDoc(v)) {
197
+ const text = richTextToPlainText(v).replace(/\s+/g, " ").trim();
198
+ return text.length > 80 ? text.slice(0, 80) + "…" : text;
199
+ }
194
200
  if (typeof v === "object") return "—";
195
201
  return String(v);
196
202
  }
@@ -265,6 +271,185 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
265
271
  );
266
272
  }
267
273
 
274
+ /** The workflow surface for a collection row — the UI half of the server's `supports`.
275
+ *
276
+ * Without this the feature is unreachable from the editor: `collectionCreate` always seeds
277
+ * `status: "draft"` and `collectionUpdate` strips `status` from the values bag (it is a
278
+ * MANAGED column, deliberately not in the write whitelist), so a row authored here could
279
+ * never be made public. Publishing has to be its own gated call, and this is where it is
280
+ * made. Every control is driven by `def.supports`, so a plain CRUD collection renders
281
+ * nothing at all.
282
+ *
283
+ * The row's managed columns come back on `collectionGet`/the write echoes, so the panel
284
+ * reads its state from the same `values` bag the form holds. */
285
+ function CollectionWorkflow({
286
+ api,
287
+ def,
288
+ id,
289
+ values,
290
+ onChanged,
291
+ onError,
292
+ }: {
293
+ api: Api;
294
+ def: CollectionMeta;
295
+ id: string;
296
+ values: FieldValues;
297
+ onChanged: (row: FieldValues) => void;
298
+ onError: (s: string) => void;
299
+ }) {
300
+ const supports = def.supports ?? [];
301
+ const [busy, setBusy] = useState(false);
302
+ const [scheduling, setScheduling] = useState(false);
303
+ const [publishAt, setPublishAt] = useState("");
304
+ const [takedownAt, setTakedownAt] = useState("");
305
+ const [preview, setPreview] = useState<string | null>(null);
306
+ const [revisions, setRevisions] = useState<Array<{ id: string; revision: number; note: string | null; actor: string | null; createdAt: string }> | null>(null);
307
+
308
+ const status = typeof values.status === "string" ? values.status : "draft";
309
+ const published = status === "published";
310
+ const str = (k: string) => (typeof values[k] === "string" && values[k] !== "" ? (values[k] as string) : null);
311
+ const scheduledAt = str("scheduledAt");
312
+ const unpublishAt = str("unpublishAt");
313
+ const publishedAt = str("publishedAt");
314
+
315
+ if (supports.length === 0) return null;
316
+
317
+ const act = async (name: string, input: Record<string, unknown> = {}) => {
318
+ setBusy(true);
319
+ try {
320
+ const row = await api.call<FieldValues>(name, { collection: def.slug, id, ...input });
321
+ // The publish/unpublish handlers echo the persisted row; `collectionSchedule` returns
322
+ // `{ ok, scheduledAt, … }`, so re-read rather than merging a non-row shape in.
323
+ if (row && typeof row === "object" && def.idField in row) onChanged(row);
324
+ else {
325
+ const fresh = await api.call<FieldValues | null>("collectionGet", { collection: def.slug, id });
326
+ if (fresh) onChanged(fresh);
327
+ }
328
+ return true;
329
+ } catch (e) {
330
+ onError(errMsg(e));
331
+ return false;
332
+ } finally {
333
+ setBusy(false);
334
+ }
335
+ };
336
+
337
+ const saveSchedule = async () => {
338
+ const at = publishAt ? fromLocalInput(publishAt) : null;
339
+ if (!at) return onError("Pick a publication date and time first.");
340
+ const down = takedownAt ? fromLocalInput(takedownAt) : null;
341
+ if (takedownAt && !down) return onError("That takedown date is not a valid date and time.");
342
+ // `unpublishAt` is PATCH semantics server-side: omitted leaves an existing takedown
343
+ // standing, `null` cancels it. Send it explicitly whenever the scheduler is open, so
344
+ // what the editor sees in the two inputs is exactly what is stored.
345
+ const ok = await act("collectionSchedule", { publishAt: Date.parse(at), unpublishAt: down ? Date.parse(down) : null });
346
+ if (ok) setScheduling(false);
347
+ };
348
+
349
+ const openScheduler = () => {
350
+ setPublishAt(toLocalInput(scheduledAt ?? publishedAt ?? new Date().toISOString()));
351
+ setTakedownAt(unpublishAt ? toLocalInput(unpublishAt) : "");
352
+ setScheduling((v) => !v);
353
+ };
354
+
355
+ const mintPreview = async () => {
356
+ setBusy(true);
357
+ try {
358
+ const r = await api.call<{ url: string }>("signCollectionPreview", { collection: def.slug, id });
359
+ const url = api.resolve(r.url);
360
+ setPreview(url);
361
+ // Best-effort: the clipboard needs a secure context and a permission, and the link is
362
+ // rendered either way.
363
+ await navigator.clipboard?.writeText(url).catch(() => {});
364
+ } catch (e) {
365
+ onError(errMsg(e));
366
+ } finally {
367
+ setBusy(false);
368
+ }
369
+ };
370
+
371
+ const loadRevisions = async () => {
372
+ if (revisions) return setRevisions(null); // toggle closed
373
+ try {
374
+ setRevisions(await api.call<NonNullable<typeof revisions>>("collectionListRevisions", { collection: def.slug, id }));
375
+ } catch (e) {
376
+ onError(errMsg(e));
377
+ }
378
+ };
379
+
380
+ const restore = async (revisionId: string) => {
381
+ if (!confirm("Restore this version? The current content is snapshotted first, so this is itself undoable.")) return;
382
+ if (await act("collectionRestoreRevision", { revisionId })) setRevisions(null);
383
+ };
384
+
385
+ return (
386
+ <div className="flex flex-col gap-3 rounded-[14px] border border-border bg-surface-card px-[18px] py-4">
387
+ <div className="flex flex-wrap items-center gap-2">
388
+ <span className="text-fg-subtle">Status</span>
389
+ <Pill status={status}>{status}</Pill>
390
+ {publishedAt && published ? <span className="text-fg-subtle">since {formatWhen(publishedAt)}</span> : null}
391
+ {scheduledAt ? <span className="text-accent-strong">publishes {formatWhen(scheduledAt)}</span> : null}
392
+ {unpublishAt ? <span className="text-danger">comes down {formatWhen(unpublishAt)}</span> : null}
393
+ </div>
394
+ <div className="flex flex-wrap items-center gap-2">
395
+ {supports.includes("drafts") && !published ? (
396
+ <Button size="sm" isDisabled={busy} onPress={() => void act("collectionPublish")}>Publish now</Button>
397
+ ) : null}
398
+ {supports.includes("drafts") && published ? (
399
+ <Button variant="secondary" size="sm" isDisabled={busy} onPress={() => void act("collectionUnpublish")}>Unpublish</Button>
400
+ ) : null}
401
+ {supports.includes("scheduling") ? (
402
+ <Button variant="secondary" size="sm" isDisabled={busy} onPress={openScheduler}>{scheduledAt ? "Change schedule" : "Schedule…"}</Button>
403
+ ) : null}
404
+ {supports.includes("preview") ? (
405
+ <Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void mintPreview()}>Preview link</Button>
406
+ ) : null}
407
+ {supports.includes("revisions") ? (
408
+ <Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void loadRevisions()}>{revisions ? "Hide history" : "History"}</Button>
409
+ ) : null}
410
+ </div>
411
+ {scheduling ? (
412
+ <div className="flex flex-col gap-2 border-t border-border pt-3">
413
+ <label className="flex flex-col gap-1 text-sm">
414
+ <span className="font-medium text-fg">Publish at</span>
415
+ <input className={CONTROL} type="datetime-local" value={publishAt} onChange={(e) => setPublishAt(e.target.value)} />
416
+ </label>
417
+ <label className="flex flex-col gap-1 text-sm">
418
+ <span className="font-medium text-fg">Take down at (optional)</span>
419
+ <input className={CONTROL} type="datetime-local" value={takedownAt} onChange={(e) => setTakedownAt(e.target.value)} />
420
+ </label>
421
+ <p className="text-fg-subtle">
422
+ Scheduling does not take a live row down — it publishes at the first instant. A takedown must be after the publication time. Leave it empty to
423
+ cancel one.
424
+ </p>
425
+ <div className="flex gap-2">
426
+ <Button size="sm" isDisabled={busy} onPress={() => void saveSchedule()}>Save schedule</Button>
427
+ <Button variant="ghost" size="sm" onPress={() => setScheduling(false)}>Cancel</Button>
428
+ </div>
429
+ </div>
430
+ ) : null}
431
+ {preview ? (
432
+ <div className="border-t border-border pt-3 text-sm">
433
+ <span className="text-fg-subtle">Preview link (copied): </span>
434
+ <a className="break-all underline" href={preview} target="_blank" rel="noreferrer">{preview}</a>
435
+ </div>
436
+ ) : null}
437
+ {revisions ? (
438
+ <div className="flex flex-col gap-2 border-t border-border pt-3">
439
+ {revisions.map((r) => (
440
+ <div className={`${ROW} text-xs`} key={r.id}>
441
+ <span className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg-muted">#{r.revision}</span>
442
+ <span className="flex-1 truncate text-fg-subtle">{r.note ?? "edit"} · {r.actor ?? "system"} · {formatWhen(r.createdAt)}</span>
443
+ <Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void restore(r.id)}>Restore</Button>
444
+ </div>
445
+ ))}
446
+ {revisions.length === 0 ? <p className="text-fg-subtle">No history yet.</p> : null}
447
+ </div>
448
+ ) : null}
449
+ </div>
450
+ );
451
+ }
452
+
268
453
  export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onError }: { api: Api; def: CollectionMeta; id: string | null; onSaved: () => void; onDeleted: () => void; onBack: () => void; onError: (s: string) => void }) {
269
454
  const isNew = id === null;
270
455
  const [values, setValues] = useState<FieldValues>({});
@@ -333,6 +518,12 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
333
518
  ) : (
334
519
  <div className="flex max-w-[720px] flex-col gap-4">
335
520
  {ok ? <Banner ok>saved</Banner> : null}
521
+ {/* Publishing is a separate, separately-gated call — `status` is a managed column
522
+ the ordinary save cannot touch — so the workflow controls live outside the
523
+ form. Only on an existing row: there is nothing to publish until it exists. */}
524
+ {!isNew && id ? (
525
+ <CollectionWorkflow api={api} def={def} id={id} values={values} onChanged={setValues} onError={onError} />
526
+ ) : null}
336
527
  <FieldForm schema={def.fields} value={values} onChange={setValues} api={api} />
337
528
  <div className="mt-2 flex items-center gap-2">
338
529
  <Button onPress={save} isDisabled={busy}>{busy ? "Saving…" : isNew ? "Create" : "Save"}</Button>
@@ -629,7 +820,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
629
820
  const blockId = block.block_id;
630
821
  const placementId = block.id;
631
822
 
632
- // Load RAW fields (media as ids, richtext as HTML) so the value round-trips on save.
823
+ // Load RAW fields (media as ids, richtext as a document tree) so the value round-trips on save.
633
824
  // A pending optimistic block has no persisted row yet — start empty and skip the fetch
634
825
  // (its temp id would 404); when it reconciles to real ids the card remounts and fetches.
635
826
  useEffect(() => {
@@ -1095,6 +1286,10 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1095
1286
  const [hasMore, setHasMore] = useState(false);
1096
1287
  const [selected, setSelected] = useState<Media | null>(null);
1097
1288
  const [busy, setBusy] = useState(false);
1289
+ // Trashed files. Deleting no longer removes the R2 object, so without this the bytes stay
1290
+ // publicly fetchable with no way to reach purgeMedia — the case a takedown request needs.
1291
+ const [trash, setTrash] = useState<Media[]>([]);
1292
+ const [showTrash, setShowTrash] = useState(false);
1098
1293
 
1099
1294
  const load = useCallback(
1100
1295
  (off: number) => {
@@ -1109,7 +1304,30 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1109
1304
  },
1110
1305
  [api, onError],
1111
1306
  );
1307
+ const loadTrash = useCallback(() => {
1308
+ api.listTrash().then((r) => setTrash(r.media ?? [])).catch((e) => onError(errMsg(e)));
1309
+ }, [api, onError]);
1112
1310
  useEffect(() => { load(0); }, [load]);
1311
+ useEffect(() => { loadTrash(); }, [loadTrash]);
1312
+
1313
+ const restore = async (id: string) => {
1314
+ try {
1315
+ await api.restoreMedia(id);
1316
+ loadTrash();
1317
+ load(0);
1318
+ } catch (e) {
1319
+ onError(errMsg(e));
1320
+ }
1321
+ };
1322
+ const purge = async (id: string) => {
1323
+ if (!confirm("Delete this file permanently? The file itself is removed and cannot be recovered.")) return;
1324
+ try {
1325
+ await api.purgeMedia(id);
1326
+ loadTrash();
1327
+ } catch (e) {
1328
+ onError(errMsg(e));
1329
+ }
1330
+ };
1113
1331
 
1114
1332
  const upload = async (files: FileList | null) => {
1115
1333
  if (!files?.length) return;
@@ -1153,13 +1371,37 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1153
1371
  <Button variant="secondary" size="sm" onPress={() => load(offset)}>Load more</Button>
1154
1372
  </div>
1155
1373
  ) : null}
1374
+ {trash.length > 0 ? (
1375
+ <div className="mt-6 border-t border-border pt-4">
1376
+ <button type="button" className="text-small text-fg-muted underline" onClick={() => setShowTrash((v) => !v)}>
1377
+ {showTrash ? "Hide" : "Show"} trash ({trash.length})
1378
+ </button>
1379
+ {showTrash ? (
1380
+ <>
1381
+ <p className="mt-2 text-small text-fg-subtle">
1382
+ Trashed files are hidden from the library but the file itself still exists — a page published
1383
+ while it was in use keeps showing it. Delete permanently to remove the file.
1384
+ </p>
1385
+ <div className="mt-2.5 flex flex-col gap-1.5">
1386
+ {trash.map((m) => (
1387
+ <div key={m.id} className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-muted px-3 py-2">
1388
+ <span className="flex-1 truncate text-small text-fg-muted">{m.file?.filename ?? m.id}</span>
1389
+ <Button variant="secondary" size="sm" onPress={() => restore(m.id)}>Restore</Button>
1390
+ <Button variant="secondary" size="sm" onPress={() => purge(m.id)}>Delete permanently</Button>
1391
+ </div>
1392
+ ))}
1393
+ </div>
1394
+ </>
1395
+ ) : null}
1396
+ </div>
1397
+ ) : null}
1156
1398
  {selected ? (
1157
1399
  <MediaDetail
1158
1400
  api={api}
1159
1401
  media={selected}
1160
1402
  onClose={() => setSelected(null)}
1161
1403
  onSaved={(m) => { setSelected(m); setMedia((prev) => prev.map((x) => (x.id === m.id ? m : x))); }}
1162
- onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); }}
1404
+ onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); loadTrash(); }}
1163
1405
  onError={onError}
1164
1406
  />
1165
1407
  ) : null}
@@ -1185,7 +1427,7 @@ function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api
1185
1427
  }
1186
1428
  };
1187
1429
  const del = async () => {
1188
- if (!confirm("Delete this file permanently? If a block or page still references it, that image will breakthis cannot be undone.")) return;
1430
+ if (!confirm("Move this file to the trash? It disappears from the library, but a page published while it was in use keeps showing it delete it permanently from the trash to remove the file itself.")) return;
1189
1431
  setBusy(true);
1190
1432
  try {
1191
1433
  await api.deleteMedia(media.id);
@@ -1479,11 +1721,22 @@ function plainText(html: string): string {
1479
1721
  .replace(/\s+/g, " ")
1480
1722
  .trim();
1481
1723
  }
1482
- /** One-line preview for a collapsed block: the first non-empty string field, tags stripped. */
1724
+ /** Readable text for one field value, whatever shape it is. A `richtext` field is a
1725
+ * document tree, so the first non-empty STRING is no longer enough — a block whose only
1726
+ * field is prose would read "empty". Legacy HTML strings still pass through `plainText`. */
1727
+ function fieldText(v: FieldValue): string {
1728
+ if (typeof v === "string") return plainText(v);
1729
+ if (isRichTextDoc(v)) return richTextToPlainText(v).replace(/\s+/g, " ").trim();
1730
+ return "";
1731
+ }
1732
+ /** One-line preview for a collapsed block: the first field with readable text in it. */
1483
1733
  function blockPreview(fields: FieldValues): string {
1484
- const first = Object.values(fields).find((v) => typeof v === "string" && v.trim());
1485
- if (typeof first !== "string") return "";
1486
- const text = plainText(first);
1734
+ let text = "";
1735
+ for (const v of Object.values(fields)) {
1736
+ text = fieldText(v);
1737
+ if (text) break;
1738
+ }
1739
+ if (!text) return "";
1487
1740
  return text.length > 90 ? text.slice(0, 90) + "…" : text;
1488
1741
  }
1489
1742
  function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: number, reorder: (region: string, order: string[]) => void) {
package/src/fields.tsx CHANGED
@@ -3,13 +3,19 @@
3
3
 
4
4
  import { Button, Heading, Input, ModalDialog, ModalOverlay, ModalSurface, Text, Textarea } from "@podoba/react";
5
5
  import { BlockEditor } from "@podoba/react/editor";
6
+ import { generateHTML, generateJSON } from "@tiptap/core";
7
+ import Highlight from "@tiptap/extension-highlight";
8
+ import TaskItem from "@tiptap/extension-task-item";
9
+ import TaskList from "@tiptap/extension-task-list";
10
+ import StarterKit from "@tiptap/starter-kit";
6
11
  import { useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
7
12
  import type { Api } from "./api";
8
- import type { FieldDefinition, FieldValue, FieldValues, Media } from "./types";
13
+ import { isRichTextDoc, richTextToPlainText } from "./rich-text";
14
+ import type { FieldDefinition, FieldValue, FieldValues, Media, RichTextDoc } from "./types";
9
15
 
10
16
  // Tokenized bare control (podoba's filled-field skin) for the native inputs that
11
17
  // don't map cleanly onto a podoba primitive (number/date/select/file).
12
- const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
18
+ export const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
13
19
 
14
20
  function FieldShell({ label, children }: { label: ReactNode; children: ReactNode }) {
15
21
  return (
@@ -33,7 +39,7 @@ function FieldShell({ label, children }: { label: ReactNode; children: ReactNode
33
39
  * Returns "" for anything Date can't parse — a legacy or hand-written column value must
34
40
  * not reach the input as `NaN-NaN-NaNTNaN:NaN`, which the browser silently discards.
35
41
  */
36
- function toLocalInput(value: string): string {
42
+ export function toLocalInput(value: string): string {
37
43
  const d = new Date(value);
38
44
  if (Number.isNaN(d.getTime())) return "";
39
45
  const pad = (n: number) => String(n).padStart(2, "0");
@@ -41,12 +47,12 @@ function toLocalInput(value: string): string {
41
47
  }
42
48
 
43
49
  /** A `datetime-local` string (local wall clock) -> the UTC ISO instant we store. */
44
- function fromLocalInput(local: string): string | null {
50
+ export function fromLocalInput(local: string): string | null {
45
51
  const at = new Date(local);
46
52
  return Number.isNaN(at.getTime()) ? null : at.toISOString();
47
53
  }
48
54
 
49
- function formatWhen(value: string): string {
55
+ export function formatWhen(value: string): string {
50
56
  const at = new Date(value);
51
57
  return Number.isNaN(at.getTime()) ? value : at.toLocaleString();
52
58
  }
@@ -195,11 +201,11 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
195
201
  <Textarea label={label} value={asText(value)} onChange={onChange} />
196
202
  );
197
203
  case "richtext":
198
- // A rich-text value is an HTML string (round-trips with the site's set:html
199
- // renderers). A legacy object value isn't editable here fall back to raw text.
204
+ // A rich-text value is a document tree. A legacy HTML string still opens (it seeds
205
+ // the editor as-is) and is upgraded to a doc by the first save.
200
206
  return (
201
207
  <FieldShell label={label}>
202
- <RichText value={typeof value === "string" ? value : ""} onChange={onChange as (v: string) => void} />
208
+ <RichText value={value as RichTextDoc | string | null} onChange={onChange as (v: RichTextDoc) => void} />
203
209
  </FieldShell>
204
210
  );
205
211
  case "number":
@@ -271,13 +277,90 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
271
277
 
272
278
  // --- rich text (WYSIWYG) --------------------------------------------------------------
273
279
  // The `richtext` field is podoba's Notion-style BlockEditor (Tiptap): `/` slash palette,
274
- // block conversion, inline bubble toolbar. Still an HTML string in/out, so it round-trips
275
- // with existing rich_text content + every set:html renderer — no value migration. The
276
- // server's sanitizeRichText() (@pramen/cms) remains the XSS boundary on write; ProseMirror
277
- // parses HTML to its schema on load, so scripts never survive into the editor either.
280
+ // block conversion, inline bubble toolbar.
281
+ //
282
+ // The STORED value is a document tree (`RichTextDoc`), never HTML. BlockEditor's own
283
+ // value contract is an HTML string its docs call that presentation-only so the
284
+ // conversion happens here, at the boundary, and the HTML never leaves this component:
285
+ // seeded from the stored doc on mount, converted back to a doc on every change.
286
+ //
287
+ // The extension set MUST match BlockEditor's, or a round-trip silently drops whatever
288
+ // only its schema knows (task lists, highlights). Kept beside it here for that reason.
289
+ const RT_EXTENSIONS = [
290
+ StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: { openOnClick: false, autolink: true } }),
291
+ Highlight,
292
+ TaskList,
293
+ TaskItem.configure({ nested: true }),
294
+ ];
295
+
296
+ /** Parse editor HTML into a document. Never throws: a parse failure yields an empty
297
+ * document rather than taking the render down (this runs on every keystroke). */
298
+ function htmlToDoc(html: string): RichTextDoc {
299
+ try {
300
+ return generateJSON(html, RT_EXTENSIONS) as RichTextDoc;
301
+ } catch (e) {
302
+ console.error("pramen/cms-editor: could not parse editor HTML", e);
303
+ return { type: "doc", content: [] };
304
+ }
305
+ }
306
+
307
+ /** Seed HTML for the editor. A legacy HTML string passes through untouched — that is the
308
+ * migration ramp (see `RichText`, which upgrades it on mount).
309
+ *
310
+ * `generateHTML` throws a RangeError for any node or mark outside RT_EXTENSIONS, and this
311
+ * runs in a useState initializer with no ErrorBoundary above it — so an un-normalized
312
+ * document (a custom `richTextSchema`, an import, a bootstrap seed, `ctx.db.exec`) would
313
+ * throw during render and blank the whole SPA, not just this field. Fail to an empty
314
+ * editor and say so instead. */
315
+ function docToEditorHtml(value: RichTextDoc | string | null | undefined): string {
316
+ if (typeof value === "string") return value;
317
+ if (!isRichTextDoc(value)) return "";
318
+ try {
319
+ return generateHTML(value, RT_EXTENSIONS);
320
+ } catch (e) {
321
+ console.error("pramen/cms-editor: rich-text document uses nodes this editor cannot render", e);
322
+ return "";
323
+ }
324
+ }
325
+
326
+ export function RichText({ value, onChange }: { value: RichTextDoc | string | null; onChange: (v: RichTextDoc) => void }) {
327
+ // BlockEditor requires its own HTML echoed back VERBATIM — normalising in render would
328
+ // re-seed the document on every keystroke and throw the caret back to the start. So the
329
+ // HTML lives in local state and the doc goes upward.
330
+ const [html, setHtml] = useState(() => docToEditorHtml(value));
331
+ const emitted = useRef<RichTextDoc | null>(null);
332
+
333
+ // Upgrade a legacy HTML value to a document AS SOON AS IT OPENS, not on first edit of
334
+ // this field. The server only tolerates a legacy string that is byte-identical to what
335
+ // is stored, and both renderers emit nothing for a string — so a value that is never
336
+ // upgraded stays invisible on the site forever. Converting on mount means any ordinary
337
+ // save (even of a sibling field) writes it back as a document.
338
+ const upgraded = useRef(false);
339
+ useEffect(() => {
340
+ if (upgraded.current || typeof value !== "string" || value === "") return;
341
+ upgraded.current = true;
342
+ const doc = htmlToDoc(value);
343
+ emitted.current = doc;
344
+ onChange(doc);
345
+ }, [value, onChange]);
346
+
347
+ // Re-seed only when the parent hands us a doc that is not the one we last emitted —
348
+ // i.e. the form switched to a different block, not our own change coming back around.
349
+ useEffect(() => {
350
+ if (value !== null && value === emitted.current) return;
351
+ setHtml(docToEditorHtml(value));
352
+ // Only the incoming value should re-seed; `html` is this effect's output, not its input.
353
+ // eslint-disable-next-line react-hooks/exhaustive-deps
354
+ }, [value]);
355
+
356
+ const handleChange = (nextHtml: string) => {
357
+ setHtml(nextHtml);
358
+ const doc = htmlToDoc(nextHtml);
359
+ emitted.current = doc;
360
+ onChange(doc);
361
+ };
278
362
 
279
- export function RichText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
280
- return <BlockEditor value={value} onChange={onChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
363
+ return <BlockEditor value={html} onChange={handleChange} minHeight={180} placeholder="Write, or press '/' for blocks…" />;
281
364
  }
282
365
 
283
366
  /**
@@ -327,7 +410,11 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
327
410
  for (const f of fields) {
328
411
  if (!readable.includes(f.type)) continue;
329
412
  const v = it[f.name];
330
- if (typeof v === "string" && v.trim()) return v.replace(/<[^>]+>/g, " ").trim().slice(0, 80);
413
+ // A `richtext` value is a document tree, so a string check alone would skip the one
414
+ // prose field an item has — exactly the row this summary exists to distinguish.
415
+ const text = isRichTextDoc(v) ? richTextToPlainText(v) : typeof v === "string" ? v.replace(/<[^>]+>/g, " ") : "";
416
+ const trimmed = text.replace(/\s+/g, " ").trim();
417
+ if (trimmed) return trimmed.slice(0, 80);
331
418
  }
332
419
  return "";
333
420
  };
@@ -0,0 +1,30 @@
1
+ // Rich-text helpers for the editor. Local mirrors of the @pramen/cms functions, kept here
2
+ // because the editor is a self-contained browser app with no server-package dependency —
3
+ // it speaks to the CMS purely over HTTP (see types.ts).
4
+
5
+ import type { RichTextDoc, RichTextNode } from "./types";
6
+
7
+ /** Is this value a rich-text document (rather than a legacy HTML string or a plain bag)? */
8
+ export function isRichTextDoc(v: unknown): v is RichTextDoc {
9
+ return typeof v === "object" && v !== null && !Array.isArray(v) && (v as RichTextDoc).type === "doc";
10
+ }
11
+
12
+ /** The block-level node types that end a line when flattening to plain text. */
13
+ const BLOCK_TYPES = new Set(["paragraph", "heading", "listItem", "taskItem", "blockquote", "codeBlock", "horizontalRule"]);
14
+
15
+ /** Flatten a rich-text document to plain text — for list cells and collapsed-block
16
+ * previews, which want the words without the structure. Mirrors `richTextToPlainText`
17
+ * in @pramen/cms. */
18
+ export function richTextToPlainText(value: RichTextDoc | null | undefined): string {
19
+ const parts: string[] = [];
20
+ const walk = (nodes: readonly RichTextNode[]): void => {
21
+ for (const node of nodes) {
22
+ if (node.type === "text") parts.push(node.text ?? "");
23
+ else if (node.type === "hardBreak") parts.push("\n");
24
+ if (node.content) walk(node.content);
25
+ if (BLOCK_TYPES.has(node.type)) parts.push("\n");
26
+ }
27
+ };
28
+ walk(value?.content ?? []);
29
+ return parts.join("").replace(/\n{2,}/g, "\n").trim();
30
+ }