@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/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 +14 -6
- package/src/app-context.tsx +2 -2
- package/src/components.tsx +278 -25
- package/src/fields.tsx +112 -25
- package/src/rich-text.ts +30 -0
- package/src/types.ts +55 -5
- package/dist/main.qy6fhg00.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
|
@@ -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?:
|
|
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> {
|
package/src/app-context.tsx
CHANGED
|
@@ -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]:
|
|
45
|
+
[k: string]: JsonValue | undefined;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
interface AppContextValue {
|
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
|
}
|
|
@@ -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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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?:
|
|
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:
|
|
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
|
|
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:
|
|
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<
|
|
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
|
|
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?:
|
|
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:
|
|
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:
|
|
895
|
-
const [fields, setFields] = useState<
|
|
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("
|
|
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
|
-
/**
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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) {
|