@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/dist/app.css +1 -1
- package/dist/index.html +1 -1
- package/dist/main.bx4zkbes.js +399 -0
- package/package.json +15 -11
- package/src/api.ts +6 -0
- package/src/components.tsx +263 -10
- package/src/fields.tsx +102 -15
- package/src/rich-text.ts +30 -0
- package/src/types.ts +34 -2
- package/dist/main.ykztb0px.js +0 -396
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms-editor",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Visual block/page editor for @pramen/cms
|
|
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": [
|
|
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.
|
|
24
|
-
"@podoba/
|
|
25
|
-
"@podoba/
|
|
26
|
-
"@tiptap/
|
|
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-
|
|
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> {
|
package/src/components.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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("
|
|
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
|
-
/**
|
|
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
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
|
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
|
|
199
|
-
//
|
|
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={
|
|
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.
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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/rich-text.ts
ADDED
|
@@ -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
|
+
}
|