@pramen/cms-editor 0.0.49 → 0.0.51

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.
@@ -5,14 +5,22 @@
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
- import type { Me } from "./app-context";
11
- import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
10
+ import { useApp, type Me } from "./app-context";
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"];
15
16
 
17
+ /** The tabs a deployment actually shows. ONE definition, used by the tab bar, the panel
18
+ * switch and the route's deep-link fallback — three places that previously each re-derived
19
+ * "is i18n visible?" and could disagree. */
20
+ export function visibleTabs(multilingual: boolean): InspectorTab[] {
21
+ return multilingual ? INSPECTOR_TABS : INSPECTOR_TABS.filter((t) => t !== "i18n");
22
+ }
23
+
16
24
  // --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
17
25
 
18
26
  const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
@@ -101,6 +109,8 @@ const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-
101
109
  // --- pages list --------------------------------------------------------------
102
110
 
103
111
  export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
112
+ // From the SERVER (listCmsCapabilities), not a local flag — see `CmsCapabilities`.
113
+ const { cms: { multilingual } } = useApp();
104
114
  const [creating, setCreating] = useState(false);
105
115
  return (
106
116
  <>
@@ -115,7 +125,7 @@ export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }:
115
125
  <div className={`${ROW} cursor-pointer hover:bg-surface-muted`} key={p.id} onClick={() => onOpen(p)}>
116
126
  <span className="flex-1 truncate font-medium">{p.title}</span>
117
127
  <span className="text-fg-subtle">/{p.slug}</span>
118
- <span className="text-fg-subtle">{p.locale}</span>
128
+ {multilingual ? <span className="text-fg-subtle">{p.locale}</span> : null}
119
129
  <Pill status={p.status}>{p.status}</Pill>
120
130
  </div>
121
131
  ))}
@@ -186,11 +196,16 @@ function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: (
186
196
  // collections, zero per-collection code. Rows are addressed by `def.idField` (the entity's
187
197
  // PK column, defaults "id"); the server resolves the real PK from the value.
188
198
 
189
- /** Render a list-cell value as a short string (objects/arrays are summarized, not dumped). */
199
+ /** Render a list-cell value as a short string (objects/arrays are summarized, not dumped).
200
+ * A `richtext` column is a document tree, so flatten it to words rather than showing "—". */
190
201
  function cellText(v: unknown): string {
191
202
  if (v == null) return "";
192
203
  if (typeof v === "boolean") return v ? "yes" : "no";
193
204
  if (Array.isArray(v)) return v.length === 1 ? "1 item" : `${v.length} items`;
205
+ if (isRichTextDoc(v)) {
206
+ const text = richTextToPlainText(v).replace(/\s+/g, " ").trim();
207
+ return text.length > 80 ? text.slice(0, 80) + "…" : text;
208
+ }
194
209
  if (typeof v === "object") return "—";
195
210
  return String(v);
196
211
  }
@@ -265,6 +280,185 @@ export function CollectionList({ api, def, onOpen, onNew, onError }: { api: Api;
265
280
  );
266
281
  }
267
282
 
283
+ /** The workflow surface for a collection row — the UI half of the server's `supports`.
284
+ *
285
+ * Without this the feature is unreachable from the editor: `collectionCreate` always seeds
286
+ * `status: "draft"` and `collectionUpdate` strips `status` from the values bag (it is a
287
+ * MANAGED column, deliberately not in the write whitelist), so a row authored here could
288
+ * never be made public. Publishing has to be its own gated call, and this is where it is
289
+ * made. Every control is driven by `def.supports`, so a plain CRUD collection renders
290
+ * nothing at all.
291
+ *
292
+ * The row's managed columns come back on `collectionGet`/the write echoes, so the panel
293
+ * reads its state from the same `values` bag the form holds. */
294
+ function CollectionWorkflow({
295
+ api,
296
+ def,
297
+ id,
298
+ values,
299
+ onChanged,
300
+ onError,
301
+ }: {
302
+ api: Api;
303
+ def: CollectionMeta;
304
+ id: string;
305
+ values: FieldValues;
306
+ onChanged: (row: FieldValues) => void;
307
+ onError: (s: string) => void;
308
+ }) {
309
+ const supports = def.supports ?? [];
310
+ const [busy, setBusy] = useState(false);
311
+ const [scheduling, setScheduling] = useState(false);
312
+ const [publishAt, setPublishAt] = useState("");
313
+ const [takedownAt, setTakedownAt] = useState("");
314
+ const [preview, setPreview] = useState<string | null>(null);
315
+ const [revisions, setRevisions] = useState<Array<{ id: string; revision: number; note: string | null; actor: string | null; createdAt: string }> | null>(null);
316
+
317
+ const status = typeof values.status === "string" ? values.status : "draft";
318
+ const published = status === "published";
319
+ const str = (k: string) => (typeof values[k] === "string" && values[k] !== "" ? (values[k] as string) : null);
320
+ const scheduledAt = str("scheduledAt");
321
+ const unpublishAt = str("unpublishAt");
322
+ const publishedAt = str("publishedAt");
323
+
324
+ if (supports.length === 0) return null;
325
+
326
+ const act = async (name: string, input: Record<string, unknown> = {}) => {
327
+ setBusy(true);
328
+ try {
329
+ const row = await api.call<FieldValues>(name, { collection: def.slug, id, ...input });
330
+ // The publish/unpublish handlers echo the persisted row; `collectionSchedule` returns
331
+ // `{ ok, scheduledAt, … }`, so re-read rather than merging a non-row shape in.
332
+ if (row && typeof row === "object" && def.idField in row) onChanged(row);
333
+ else {
334
+ const fresh = await api.call<FieldValues | null>("collectionGet", { collection: def.slug, id });
335
+ if (fresh) onChanged(fresh);
336
+ }
337
+ return true;
338
+ } catch (e) {
339
+ onError(errMsg(e));
340
+ return false;
341
+ } finally {
342
+ setBusy(false);
343
+ }
344
+ };
345
+
346
+ const saveSchedule = async () => {
347
+ const at = publishAt ? fromLocalInput(publishAt) : null;
348
+ if (!at) return onError("Pick a publication date and time first.");
349
+ const down = takedownAt ? fromLocalInput(takedownAt) : null;
350
+ if (takedownAt && !down) return onError("That takedown date is not a valid date and time.");
351
+ // `unpublishAt` is PATCH semantics server-side: omitted leaves an existing takedown
352
+ // standing, `null` cancels it. Send it explicitly whenever the scheduler is open, so
353
+ // what the editor sees in the two inputs is exactly what is stored.
354
+ const ok = await act("collectionSchedule", { publishAt: Date.parse(at), unpublishAt: down ? Date.parse(down) : null });
355
+ if (ok) setScheduling(false);
356
+ };
357
+
358
+ const openScheduler = () => {
359
+ setPublishAt(toLocalInput(scheduledAt ?? publishedAt ?? new Date().toISOString()));
360
+ setTakedownAt(unpublishAt ? toLocalInput(unpublishAt) : "");
361
+ setScheduling((v) => !v);
362
+ };
363
+
364
+ const mintPreview = async () => {
365
+ setBusy(true);
366
+ try {
367
+ const r = await api.call<{ url: string }>("signCollectionPreview", { collection: def.slug, id });
368
+ const url = api.resolve(r.url);
369
+ setPreview(url);
370
+ // Best-effort: the clipboard needs a secure context and a permission, and the link is
371
+ // rendered either way.
372
+ await navigator.clipboard?.writeText(url).catch(() => {});
373
+ } catch (e) {
374
+ onError(errMsg(e));
375
+ } finally {
376
+ setBusy(false);
377
+ }
378
+ };
379
+
380
+ const loadRevisions = async () => {
381
+ if (revisions) return setRevisions(null); // toggle closed
382
+ try {
383
+ setRevisions(await api.call<NonNullable<typeof revisions>>("collectionListRevisions", { collection: def.slug, id }));
384
+ } catch (e) {
385
+ onError(errMsg(e));
386
+ }
387
+ };
388
+
389
+ const restore = async (revisionId: string) => {
390
+ if (!confirm("Restore this version? The current content is snapshotted first, so this is itself undoable.")) return;
391
+ if (await act("collectionRestoreRevision", { revisionId })) setRevisions(null);
392
+ };
393
+
394
+ return (
395
+ <div className="flex flex-col gap-3 rounded-[14px] border border-border bg-surface-card px-[18px] py-4">
396
+ <div className="flex flex-wrap items-center gap-2">
397
+ <span className="text-fg-subtle">Status</span>
398
+ <Pill status={status}>{status}</Pill>
399
+ {publishedAt && published ? <span className="text-fg-subtle">since {formatWhen(publishedAt)}</span> : null}
400
+ {scheduledAt ? <span className="text-accent-strong">publishes {formatWhen(scheduledAt)}</span> : null}
401
+ {unpublishAt ? <span className="text-danger">comes down {formatWhen(unpublishAt)}</span> : null}
402
+ </div>
403
+ <div className="flex flex-wrap items-center gap-2">
404
+ {supports.includes("drafts") && !published ? (
405
+ <Button size="sm" isDisabled={busy} onPress={() => void act("collectionPublish")}>Publish now</Button>
406
+ ) : null}
407
+ {supports.includes("drafts") && published ? (
408
+ <Button variant="secondary" size="sm" isDisabled={busy} onPress={() => void act("collectionUnpublish")}>Unpublish</Button>
409
+ ) : null}
410
+ {supports.includes("scheduling") ? (
411
+ <Button variant="secondary" size="sm" isDisabled={busy} onPress={openScheduler}>{scheduledAt ? "Change schedule" : "Schedule…"}</Button>
412
+ ) : null}
413
+ {supports.includes("preview") ? (
414
+ <Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void mintPreview()}>Preview link</Button>
415
+ ) : null}
416
+ {supports.includes("revisions") ? (
417
+ <Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void loadRevisions()}>{revisions ? "Hide history" : "History"}</Button>
418
+ ) : null}
419
+ </div>
420
+ {scheduling ? (
421
+ <div className="flex flex-col gap-2 border-t border-border pt-3">
422
+ <label className="flex flex-col gap-1 text-sm">
423
+ <span className="font-medium text-fg">Publish at</span>
424
+ <input className={CONTROL} type="datetime-local" value={publishAt} onChange={(e) => setPublishAt(e.target.value)} />
425
+ </label>
426
+ <label className="flex flex-col gap-1 text-sm">
427
+ <span className="font-medium text-fg">Take down at (optional)</span>
428
+ <input className={CONTROL} type="datetime-local" value={takedownAt} onChange={(e) => setTakedownAt(e.target.value)} />
429
+ </label>
430
+ <p className="text-fg-subtle">
431
+ 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
432
+ cancel one.
433
+ </p>
434
+ <div className="flex gap-2">
435
+ <Button size="sm" isDisabled={busy} onPress={() => void saveSchedule()}>Save schedule</Button>
436
+ <Button variant="ghost" size="sm" onPress={() => setScheduling(false)}>Cancel</Button>
437
+ </div>
438
+ </div>
439
+ ) : null}
440
+ {preview ? (
441
+ <div className="border-t border-border pt-3 text-sm">
442
+ <span className="text-fg-subtle">Preview link (copied): </span>
443
+ <a className="break-all underline" href={preview} target="_blank" rel="noreferrer">{preview}</a>
444
+ </div>
445
+ ) : null}
446
+ {revisions ? (
447
+ <div className="flex flex-col gap-2 border-t border-border pt-3">
448
+ {revisions.map((r) => (
449
+ <div className={`${ROW} text-xs`} key={r.id}>
450
+ <span className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg-muted">#{r.revision}</span>
451
+ <span className="flex-1 truncate text-fg-subtle">{r.note ?? "edit"} · {r.actor ?? "system"} · {formatWhen(r.createdAt)}</span>
452
+ <Button variant="ghost" size="sm" isDisabled={busy} onPress={() => void restore(r.id)}>Restore</Button>
453
+ </div>
454
+ ))}
455
+ {revisions.length === 0 ? <p className="text-fg-subtle">No history yet.</p> : null}
456
+ </div>
457
+ ) : null}
458
+ </div>
459
+ );
460
+ }
461
+
268
462
  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
463
  const isNew = id === null;
270
464
  const [values, setValues] = useState<FieldValues>({});
@@ -333,6 +527,12 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
333
527
  ) : (
334
528
  <div className="flex max-w-[720px] flex-col gap-4">
335
529
  {ok ? <Banner ok>saved</Banner> : null}
530
+ {/* Publishing is a separate, separately-gated call — `status` is a managed column
531
+ the ordinary save cannot touch — so the workflow controls live outside the
532
+ form. Only on an existing row: there is nothing to publish until it exists. */}
533
+ {!isNew && id ? (
534
+ <CollectionWorkflow api={api} def={def} id={id} values={values} onChanged={setValues} onError={onError} />
535
+ ) : null}
336
536
  <FieldForm schema={def.fields} value={values} onChange={setValues} api={api} />
337
537
  <div className="mt-2 flex items-center gap-2">
338
538
  <Button onPress={save} isDisabled={busy}>{busy ? "Saving…" : isNew ? "Create" : "Save"}</Button>
@@ -347,6 +547,7 @@ export function CollectionEditor({ api, def, id, onSaved, onDeleted, onBack, onE
347
547
  // --- page editor -------------------------------------------------------------
348
548
 
349
549
  export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange, registerGuard }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void; registerGuard: (fn: (() => boolean) | null) => void }) {
550
+ const { cms: { multilingual } } = useApp();
350
551
  const [ct, setCt] = useState<ContentType | null>(null);
351
552
  const [assembled, setAssembled] = useState<AssembledPage | null>(null);
352
553
  const [err, setErr] = useState("");
@@ -585,14 +786,14 @@ export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange
585
786
 
586
787
  <div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
587
788
  <div className="mb-3 flex gap-1">
588
- {INSPECTOR_TABS.map((t) => (
789
+ {visibleTabs(multilingual).map((t) => (
589
790
  <Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
590
791
  ))}
591
792
  </div>
592
793
  {tab === "settings" ? <PageMeta api={api} page={page} onSaved={onChange} onError={setErr} /> : null}
593
794
  {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
594
795
  {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
595
- {tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
796
+ {tab === "i18n" && multilingual ? <I18n api={api} page={page} onError={setErr} /> : null}
596
797
  {tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
597
798
  </div>
598
799
  </div>
@@ -629,7 +830,7 @@ function BlockCard({ api, block, blockType, isFirst, isLast, onMove, onRemove, o
629
830
  const blockId = block.block_id;
630
831
  const placementId = block.id;
631
832
 
632
- // Load RAW fields (media as ids, richtext as HTML) so the value round-trips on save.
833
+ // Load RAW fields (media as ids, richtext as a document tree) so the value round-trips on save.
633
834
  // A pending optimistic block has no persisted row yet — start empty and skip the fetch
634
835
  // (its temp id would 404); when it reconciles to real ids the card remounts and fetches.
635
836
  useEffect(() => {
@@ -853,6 +1054,7 @@ function Inserter({ allowed, btBySlug, onAdd, compact }: { allowed: string[]; bt
853
1054
  * type has no regions look empty — a wide, blank canvas next to a cramped form.
854
1055
  */
855
1056
  function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSaved: (p: Page) => void; onError: (s: string) => void }) {
1057
+ const { cms: { multilingual, locales } } = useApp();
856
1058
  const [title, setTitle] = useState(page.title);
857
1059
  const [slug, setSlug] = useState(page.slug);
858
1060
  const [locale, setLocale] = useState(page.locale);
@@ -866,7 +1068,12 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
866
1068
  try {
867
1069
  // Meta only — `fields` is deliberately omitted so saving here can never clobber
868
1070
  // content edited in the canvas (updatePage patches only what it is given).
869
- const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, title, slug, locale });
1071
+ // `locale` is sent ONLY where it is editable. On a single-locale deployment there is
1072
+ // no control for it, so including it would blind-overwrite whatever the row holds
1073
+ // with mount-time state — reverting an import or another editor's change through a
1074
+ // field this user cannot see. `updatePage` treats an absent key as no-change.
1075
+ const patch = multilingual ? { title, slug: slug.trim(), locale } : { title, slug: slug.trim() };
1076
+ const r = await api.call<{ page?: Page }>("updatePage", { pageId: page.id, ...patch });
870
1077
  if (r?.page) onSaved(r.page);
871
1078
  setOk(true);
872
1079
  setTimeout(() => setOk(false), 1500);
@@ -883,7 +1090,18 @@ function PageMeta({ api, page, onSaved, onError }: { api: Api; page: Page; onSav
883
1090
  {ok ? <Banner ok>saved</Banner> : null}
884
1091
  <Input label="Title" value={title} onChange={setTitle} />
885
1092
  <Input label="Slug" value={slug} onChange={setSlug} />
886
- <Input label="Locale" value={locale} onChange={setLocale} />
1093
+ {/* A SELECT over the declared locales, not free text: a typo'd or blank locale saves
1094
+ fine, previews fine (the editor round-trips the same string) and then 404s on the
1095
+ live site, which is the hardest kind of wrong to see. */}
1096
+ {multilingual ? (
1097
+ <label className="flex flex-col gap-1 text-sm">
1098
+ <span className="font-medium text-fg">Locale</span>
1099
+ <select className={CONTROL} value={locale} onChange={(e) => setLocale(e.target.value)}>
1100
+ {locales.includes(locale) ? null : <option value={locale}>{locale || "(unset)"} — not a declared locale</option>}
1101
+ {locales.map((l) => <option key={l} value={l}>{l}</option>)}
1102
+ </select>
1103
+ </label>
1104
+ ) : null}
887
1105
  <Button onPress={save} isDisabled={busy || !title.trim() || !slug.trim()}>{busy ? "Saving…" : "Save"}</Button>
888
1106
  <KV><span>Status</span><span>{page.status}</span></KV>
889
1107
  </div>
@@ -1095,6 +1313,10 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1095
1313
  const [hasMore, setHasMore] = useState(false);
1096
1314
  const [selected, setSelected] = useState<Media | null>(null);
1097
1315
  const [busy, setBusy] = useState(false);
1316
+ // Trashed files. Deleting no longer removes the R2 object, so without this the bytes stay
1317
+ // publicly fetchable with no way to reach purgeMedia — the case a takedown request needs.
1318
+ const [trash, setTrash] = useState<Media[]>([]);
1319
+ const [showTrash, setShowTrash] = useState(false);
1098
1320
 
1099
1321
  const load = useCallback(
1100
1322
  (off: number) => {
@@ -1109,7 +1331,30 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1109
1331
  },
1110
1332
  [api, onError],
1111
1333
  );
1334
+ const loadTrash = useCallback(() => {
1335
+ api.listTrash().then((r) => setTrash(r.media ?? [])).catch((e) => onError(errMsg(e)));
1336
+ }, [api, onError]);
1112
1337
  useEffect(() => { load(0); }, [load]);
1338
+ useEffect(() => { loadTrash(); }, [loadTrash]);
1339
+
1340
+ const restore = async (id: string) => {
1341
+ try {
1342
+ await api.restoreMedia(id);
1343
+ loadTrash();
1344
+ load(0);
1345
+ } catch (e) {
1346
+ onError(errMsg(e));
1347
+ }
1348
+ };
1349
+ const purge = async (id: string) => {
1350
+ if (!confirm("Delete this file permanently? The file itself is removed and cannot be recovered.")) return;
1351
+ try {
1352
+ await api.purgeMedia(id);
1353
+ loadTrash();
1354
+ } catch (e) {
1355
+ onError(errMsg(e));
1356
+ }
1357
+ };
1113
1358
 
1114
1359
  const upload = async (files: FileList | null) => {
1115
1360
  if (!files?.length) return;
@@ -1153,13 +1398,37 @@ export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string)
1153
1398
  <Button variant="secondary" size="sm" onPress={() => load(offset)}>Load more</Button>
1154
1399
  </div>
1155
1400
  ) : null}
1401
+ {trash.length > 0 ? (
1402
+ <div className="mt-6 border-t border-border pt-4">
1403
+ <button type="button" className="text-small text-fg-muted underline" onClick={() => setShowTrash((v) => !v)}>
1404
+ {showTrash ? "Hide" : "Show"} trash ({trash.length})
1405
+ </button>
1406
+ {showTrash ? (
1407
+ <>
1408
+ <p className="mt-2 text-small text-fg-subtle">
1409
+ Trashed files are hidden from the library but the file itself still exists — a page published
1410
+ while it was in use keeps showing it. Delete permanently to remove the file.
1411
+ </p>
1412
+ <div className="mt-2.5 flex flex-col gap-1.5">
1413
+ {trash.map((m) => (
1414
+ <div key={m.id} className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-muted px-3 py-2">
1415
+ <span className="flex-1 truncate text-small text-fg-muted">{m.file?.filename ?? m.id}</span>
1416
+ <Button variant="secondary" size="sm" onPress={() => restore(m.id)}>Restore</Button>
1417
+ <Button variant="secondary" size="sm" onPress={() => purge(m.id)}>Delete permanently</Button>
1418
+ </div>
1419
+ ))}
1420
+ </div>
1421
+ </>
1422
+ ) : null}
1423
+ </div>
1424
+ ) : null}
1156
1425
  {selected ? (
1157
1426
  <MediaDetail
1158
1427
  api={api}
1159
1428
  media={selected}
1160
1429
  onClose={() => setSelected(null)}
1161
1430
  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)); }}
1431
+ onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); loadTrash(); }}
1163
1432
  onError={onError}
1164
1433
  />
1165
1434
  ) : null}
@@ -1185,7 +1454,7 @@ function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api
1185
1454
  }
1186
1455
  };
1187
1456
  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;
1457
+ 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
1458
  setBusy(true);
1190
1459
  try {
1191
1460
  await api.deleteMedia(media.id);
@@ -1479,11 +1748,22 @@ function plainText(html: string): string {
1479
1748
  .replace(/\s+/g, " ")
1480
1749
  .trim();
1481
1750
  }
1482
- /** One-line preview for a collapsed block: the first non-empty string field, tags stripped. */
1751
+ /** Readable text for one field value, whatever shape it is. A `richtext` field is a
1752
+ * document tree, so the first non-empty STRING is no longer enough — a block whose only
1753
+ * field is prose would read "empty". Legacy HTML strings still pass through `plainText`. */
1754
+ function fieldText(v: FieldValue): string {
1755
+ if (typeof v === "string") return plainText(v);
1756
+ if (isRichTextDoc(v)) return richTextToPlainText(v).replace(/\s+/g, " ").trim();
1757
+ return "";
1758
+ }
1759
+ /** One-line preview for a collapsed block: the first field with readable text in it. */
1483
1760
  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);
1761
+ let text = "";
1762
+ for (const v of Object.values(fields)) {
1763
+ text = fieldText(v);
1764
+ if (text) break;
1765
+ }
1766
+ if (!text) return "";
1487
1767
  return text.length > 90 ? text.slice(0, 90) + "…" : text;
1488
1768
  }
1489
1769
  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
  };
package/src/main.tsx CHANGED
@@ -3,10 +3,20 @@ import { StrictMode } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { pageRegistry, routes } from "virtual:buzola/routes";
5
5
  import { AppProvider } from "./app-context";
6
+ import { DOCUMENT_TITLE } from "./brand";
6
7
 
7
8
  // Styling is podoba: @podoba/tokens/variables.css + the compiled Tailwind (podoba
8
9
  // preset) are <link>ed by index.html (see scripts/build.ts). No more inline CSS.
9
10
 
11
+ // The <title> in index.html is baked at build time, before any host config exists, so it
12
+ // can only be the default. Re-apply the configured wordmark once /config.js has been read —
13
+ // the static tag stays the pre-hydration fallback.
14
+ //
15
+ // `DOCUMENT_TITLE`, not `${BRAND.title} editor`: appending a fixed English noun to the one
16
+ // string this feature exists to hand over would put a foreign word in a rebranded client's
17
+ // tab, and `suffix: null` ("just our name") could never drop it.
18
+ document.title = DOCUMENT_TITLE;
19
+
10
20
  const el = document.getElementById("app");
11
21
  if (el)
12
22
  createRoot(el).render(