@pramen/cms-editor 0.0.48 → 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.48",
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
@@ -2,6 +2,7 @@
2
2
  // same transport shape as @pramen/admin's api.ts. Config is persisted in localStorage.
3
3
 
4
4
  import type { AssembledPage, AuditEntry, BlockType, ContentType, Media, Page } from "./types";
5
+ import type { RpcInput } from "./types";
5
6
 
6
7
  export interface Config {
7
8
  baseUrl: string;
@@ -72,20 +73,21 @@ export class Api {
72
73
  }
73
74
 
74
75
  /** Call a CMS RPC handler. Throws ApiError on a non-`ok` envelope. */
75
- async call<T = unknown>(name: string, input?: unknown): Promise<T> {
76
+ async call<T = unknown>(name: string, input?: RpcInput): Promise<T> {
76
77
  // Expired token: hand off to sign-in instead of firing a request that will 403 into an
77
78
  // error banner. The returned promise never settles — navigation is already underway.
78
79
  if (this.onExpired && this.cfg.token && isTokenExpired(this.cfg.token)) {
79
80
  this.onExpired();
80
81
  return new Promise<T>(() => {});
81
82
  }
83
+ const headers = new Headers({
84
+ "content-type": "application/json",
85
+ "x-pramen-tenant": this.cfg.tenant || "main",
86
+ });
87
+ if (this.cfg.token) headers.set("authorization", `Bearer ${this.cfg.token}`);
82
88
  const res = await fetch(`${this.base()}/rpc/${name}`, {
83
89
  method: "POST",
84
- headers: {
85
- "content-type": "application/json",
86
- "x-pramen-tenant": this.cfg.tenant || "main",
87
- ...(this.cfg.token ? { authorization: `Bearer ${this.cfg.token}` } : {}),
88
- },
90
+ headers,
89
91
  body: JSON.stringify(input ?? {}),
90
92
  });
91
93
  let body: { ok?: boolean; result?: unknown; error?: string; code?: string };
@@ -127,6 +129,12 @@ export class Api {
127
129
  getMedia = (id: string) => this.call<Media | null>("getMedia", { id });
128
130
  updateMedia = (id: string, alt: string | null) => this.call<Media>("updateMedia", { id, alt });
129
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 });
130
138
 
131
139
  /** Full upload flow: sign → PUT the bytes → persist a `cms_media` row. Returns the row. */
132
140
  async uploadMedia(file: File): Promise<Media> {
@@ -6,7 +6,7 @@
6
6
  import { Button, Input } from "@podoba/react";
7
7
  import { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
9
- import type { CollectionMeta } from "./types";
9
+ import type { CollectionMeta, JsonValue } from "./types";
10
10
 
11
11
  declare global {
12
12
  interface Window {
@@ -42,7 +42,7 @@ function redirectToSignIn(): void {
42
42
  export interface Me {
43
43
  userId?: string;
44
44
  roles?: string[];
45
- [k: string]: unknown;
45
+ [k: string]: JsonValue | undefined;
46
46
  }
47
47
 
48
48
  interface AppContextValue {
@@ -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, 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
  }
@@ -201,7 +207,7 @@ function cellText(v: unknown): string {
201
207
  const COLLECTION_PAGE_SIZE = 50;
202
208
 
203
209
  export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api; def: CollectionMeta; onOpen: (id: string) => void; onNew: () => void; onError: (s: string) => void }) {
204
- const [rows, setRows] = useState<Record<string, unknown>[]>([]);
210
+ const [rows, setRows] = useState<FieldValues[]>([]);
205
211
  const [offset, setOffset] = useState(0);
206
212
  const [hasMore, setHasMore] = useState(false);
207
213
  const [loading, setLoading] = useState(true);
@@ -210,7 +216,7 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
210
216
  (off: number) => {
211
217
  setLoading(true);
212
218
  return api
213
- .call<Record<string, unknown>[]>("collectionList", { collection: def.slug, limit: COLLECTION_PAGE_SIZE, offset: off })
219
+ .call<FieldValues[]>("collectionList", { collection: def.slug, limit: COLLECTION_PAGE_SIZE, offset: off })
214
220
  .then((r) => {
215
221
  setRows((prev) => (off === 0 ? r : [...prev, ...r]));
216
222
  // A full page means there is probably more; a short one is definitely the end.
@@ -265,9 +271,188 @@ 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
- const [values, setValues] = useState<Record<string, unknown>>({});
455
+ const [values, setValues] = useState<FieldValues>({});
271
456
  const [loading, setLoading] = useState(!isNew);
272
457
  const [missing, setMissing] = useState(false);
273
458
  const [busy, setBusy] = useState(false);
@@ -278,7 +463,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
278
463
  let live = true;
279
464
  setLoading(true);
280
465
  setMissing(false);
281
- api.call<Record<string, unknown> | null>("collectionGet", { collection: def.slug, id })
466
+ api.call<FieldValues | null>("collectionGet", { collection: def.slug, id })
282
467
  .then((row) => { if (!live) return; if (row) setValues(row); else setMissing(true); })
283
468
  .catch((e) => onError(errMsg(e)))
284
469
  .finally(() => { if (live) setLoading(false); });
@@ -296,7 +481,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
296
481
  } else {
297
482
  // Editing: stay on the form. Reflect the persisted row the update echoes back (server
298
483
  // defaults / normalization applied) and flash a confirmation instead of navigating.
299
- const updated = await api.call<Record<string, unknown>>("collectionUpdate", { collection: def.slug, id, values });
484
+ const updated = await api.call<FieldValues>("collectionUpdate", { collection: def.slug, id, values });
300
485
  if (updated) setValues(updated);
301
486
  setOk(true);
302
487
  setTimeout(() => setOk(false), 1200);
@@ -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>
@@ -427,7 +618,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
427
618
  });
428
619
  try {
429
620
  const { block, placement } = await api.call<{
430
- block: { id: string; title?: string | null; fields?: Record<string, unknown> | null };
621
+ block: { id: string; title?: string | null; fields?: FieldValues | null };
431
622
  placement: { id: string; isShared?: boolean | number };
432
623
  }>("addBlock", { pageId: page.id, blockTypeSlug: slug, region, fields: {} });
433
624
  const rb: RenderedBlock = {
@@ -470,7 +661,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
470
661
 
471
662
  // Patch a block's raw fields into local state after an inline save — keeps the collapsed
472
663
  // preview fresh without a full reload (which would remount every editor + lose caret/focus).
473
- const patchBlockFields = useCallback((placementId: string, fields: Record<string, unknown>) => {
664
+ const patchBlockFields = useCallback((placementId: string, fields: FieldValues) => {
474
665
  setAssembled((prev) => {
475
666
  if (!prev) return prev;
476
667
  const next: Record<string, RenderedBlock[]> = {};
@@ -539,7 +730,7 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
539
730
  not in the inspector. For a content type with no regions (a fixed layout, all
540
731
  of it page fields) this is the entire editor; the canvas is never empty. */}
541
732
  {pageSchema.length ? (
542
- <PageFields api={api} page={page} schema={pageSchema} initialFields={(assembled?.page.fields as Record<string, unknown>) ?? {}} onDirtyChange={reportDirty} onError={setErr} />
733
+ <PageFields api={api} page={page} schema={pageSchema} initialFields={(assembled?.page.fields as FieldValues) ?? {}} onDirtyChange={reportDirty} onError={setErr} />
543
734
  ) : null}
544
735
  {regions.map((r) => {
545
736
  const blocks = assembled?.regions[r.name] ?? [];
@@ -611,7 +802,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
611
802
  isLast: boolean;
612
803
  onMove: (dir: number) => void;
613
804
  onRemove: () => void;
614
- onPatch: (placementId: string, fields: Record<string, unknown>) => void;
805
+ onPatch: (placementId: string, fields: FieldValues) => void;
615
806
  onDirtyChange: (placementId: string, dirty: boolean) => void;
616
807
  onError: (s: string) => void;
617
808
  dragging: boolean;
@@ -621,7 +812,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
621
812
  onDropBlock: () => void;
622
813
  onDragEndBlock: () => void;
623
814
  }) {
624
- const [fields, setFields] = useState<Record<string, unknown> | null>(null);
815
+ const [fields, setFields] = useState<FieldValues | null>(null);
625
816
  const [collapsed, setCollapsed] = useState(false);
626
817
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle");
627
818
  const saved = useRef<string>(""); // JSON of the last-persisted fields — the dirty baseline
@@ -629,13 +820,13 @@ 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(() => {
636
827
  if (block.pending) { setFields({}); saved.current = "{}"; return; }
637
828
  let alive = true;
638
- api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId }).then((b) => {
829
+ api.call<{ fields?: FieldValues }>("getBlock", { blockId }).then((b) => {
639
830
  if (!alive) return;
640
831
  const f = b?.fields ?? {};
641
832
  setFields(f);
@@ -680,7 +871,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
680
871
  return () => clearTimeout(t);
681
872
  }, [fields, block.pending]);
682
873
 
683
- const change = (next: Record<string, unknown>) => setFields(next);
874
+ const change = (next: FieldValues) => setFields(next);
684
875
 
685
876
  // Report dirty state up (for the editor's leave/unload guard); clear it on unmount so a
686
877
  // removed block never leaves a stale "unsaved" flag behind.
@@ -891,8 +1082,8 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
891
1082
  }
892
1083
 
893
1084
  /** The page's own FIELDS — its content. Rendered in the canvas, at full width. */
894
- function PageFields({ api, page, schema, initialFields, onDirtyChange, onError }: { api: Api; page: Page; schema: FieldDefinition[]; initialFields: Record<string, unknown>; onDirtyChange: (id: string, dirty: boolean) => void; onError: (s: string) => void }) {
895
- const [fields, setFields] = useState<Record<string, unknown>>(initialFields);
1085
+ function PageFields({ api, page, schema, initialFields, onDirtyChange, onError }: { api: Api; page: Page; schema: FieldDefinition[]; initialFields: FieldValues; onDirtyChange: (id: string, dirty: boolean) => void; onError: (s: string) => void }) {
1086
+ const [fields, setFields] = useState<FieldValues>(initialFields);
896
1087
  const [ok, setOk] = useState(false);
897
1088
  const [busy, setBusy] = useState(false);
898
1089
 
@@ -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. */
1483
- function blockPreview(fields: Record<string, unknown>): 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);
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. */
1733
+ function blockPreview(fields: FieldValues): string {
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) {