@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/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
|
}
|
|
@@ -147,8 +153,8 @@ function PublishControl({ value, onChange }: { value: string; onChange: (v: stri
|
|
|
147
153
|
);
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value:
|
|
151
|
-
const set = (name: string, v:
|
|
156
|
+
export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: FieldValues; onChange: (v: FieldValues) => void; api: Api }) {
|
|
157
|
+
const set = (name: string, v: FieldValue) => onChange({ ...value, [name]: v });
|
|
152
158
|
return (
|
|
153
159
|
<div className="flex flex-col gap-4">
|
|
154
160
|
{schema.map((def) => (
|
|
@@ -171,13 +177,13 @@ export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefin
|
|
|
171
177
|
* accessible-name computation via `aria-labelledby`. Those three go through the bare
|
|
172
178
|
* `CONTROL` skin instead, the same way `number`/`date` already do.
|
|
173
179
|
*/
|
|
174
|
-
function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value:
|
|
180
|
+
function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def: FieldDefinition; value: FieldValue; onChange: (v: FieldValue) => void; api: Api; hideLabelAs?: string; siblings?: FieldValues }) {
|
|
175
181
|
const label: ReactNode = hideLabelAs !== undefined ? undefined : (
|
|
176
182
|
<>
|
|
177
183
|
{def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
|
|
178
184
|
</>
|
|
179
185
|
);
|
|
180
|
-
const asText = (v:
|
|
186
|
+
const asText = (v: FieldValue) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
|
|
181
187
|
switch (def.type) {
|
|
182
188
|
case "text":
|
|
183
189
|
case "url": {
|
|
@@ -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":
|
|
@@ -258,12 +264,12 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
258
264
|
return (
|
|
259
265
|
<FieldShell label={label}>
|
|
260
266
|
<div className="rounded-lg border border-border bg-surface-muted p-3.5">
|
|
261
|
-
<FieldForm schema={def.fields ?? []} value={(value as
|
|
267
|
+
<FieldForm schema={def.fields ?? []} value={(value as FieldValues) ?? {}} onChange={onChange as (v: FieldValues) => void} api={api} />
|
|
262
268
|
</div>
|
|
263
269
|
</FieldShell>
|
|
264
270
|
);
|
|
265
271
|
case "repeater":
|
|
266
|
-
return <Repeater def={def} value={(value as
|
|
272
|
+
return <Repeater def={def} value={(value as FieldValues[]) ?? []} onChange={onChange as (v: FieldValues[]) => void} api={api} label={label} />;
|
|
267
273
|
default:
|
|
268
274
|
return null;
|
|
269
275
|
}
|
|
@@ -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
|
/**
|
|
@@ -292,7 +375,7 @@ export function RichText({ value, onChange }: { value: string; onChange: (v: str
|
|
|
292
375
|
* Reordering is drag-and-drop from the ⠿ handle, mirroring the block canvas. ↑/↓ stay,
|
|
293
376
|
* because dragging is unavailable to keyboard users and awkward on touch.
|
|
294
377
|
*/
|
|
295
|
-
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value:
|
|
378
|
+
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: FieldValues[]; onChange: (v: FieldValues[]) => void; api: Api; label: ReactNode }) {
|
|
296
379
|
const items = Array.isArray(value) ? value : [];
|
|
297
380
|
const fields = def.fields ?? [];
|
|
298
381
|
// One field, and not itself a tall control — the case where the card is pure overhead.
|
|
@@ -300,7 +383,7 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
300
383
|
|
|
301
384
|
const [drag, setDrag] = useState<{ from: number; over: number } | null>(null);
|
|
302
385
|
|
|
303
|
-
const upd = (i: number, v:
|
|
386
|
+
const upd = (i: number, v: FieldValues) => onChange(items.map((it, j) => (j === i ? v : it)));
|
|
304
387
|
const add = () => onChange([...items, {}]);
|
|
305
388
|
const del = (i: number) => onChange(items.filter((_, j) => j !== i));
|
|
306
389
|
const moveTo = (from: number, to: number) => {
|
|
@@ -322,12 +405,16 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
322
405
|
* instead titled every slide with the uuid of its image, which is worse than no summary
|
|
323
406
|
* at all — the point is to tell two rows apart at a glance.
|
|
324
407
|
*/
|
|
325
|
-
const summarise = (it:
|
|
408
|
+
const summarise = (it: FieldValues): string => {
|
|
326
409
|
const readable = ["text", "textarea", "richtext", "select", "url"];
|
|
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
|
};
|
|
@@ -545,7 +632,7 @@ function SlugField({ def, label, value, source, onChange }: { def: FieldDefiniti
|
|
|
545
632
|
// A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
|
|
546
633
|
// fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
|
|
547
634
|
// campaigns — so the editor never has to hardcode or copy identifiers by hand.
|
|
548
|
-
function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v:
|
|
635
|
+
function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: FieldValue) => void; api: Api; ariaLabel?: string }) {
|
|
549
636
|
const [dyn, setDyn] = useState<{ value: string; label: string }[] | null>(null);
|
|
550
637
|
const from = def.optionsFrom;
|
|
551
638
|
useEffect(() => {
|
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
// @pramen/cms) so the editor stays a self-contained browser app with no server-package
|
|
3
3
|
// dependency — it speaks to the CMS purely over HTTP.
|
|
4
4
|
|
|
5
|
+
/** Any JSON value — the wire form of everything the CMS stores. */
|
|
6
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
7
|
+
|
|
8
|
+
/** A rich-text document — the editor's structured JSON. Mirrors `RichTextDoc` in
|
|
9
|
+
* @pramen/cms; a `richtext` field is this tree, never an HTML string. */
|
|
10
|
+
export interface RichTextDoc {
|
|
11
|
+
type: "doc";
|
|
12
|
+
content?: RichTextNode[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One node in a {@link RichTextDoc}. */
|
|
16
|
+
export interface RichTextNode {
|
|
17
|
+
type: string;
|
|
18
|
+
content?: RichTextNode[];
|
|
19
|
+
text?: string;
|
|
20
|
+
marks?: RichTextMark[];
|
|
21
|
+
attrs?: Record<string, JsonValue>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An inline mark on a text node. */
|
|
25
|
+
export interface RichTextMark {
|
|
26
|
+
type: string;
|
|
27
|
+
attrs?: Record<string, JsonValue>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One authored field value. Mirrors `FieldValue` in @pramen/cms: a `"media"` field
|
|
31
|
+
* arrives resolved to a `Media`, a `"richtext"` field is a `RichTextDoc`, and
|
|
32
|
+
* `group`/`repeater` fields nest further bags. */
|
|
33
|
+
export type FieldValue = JsonValue | Media | RichTextDoc | FieldValues | FieldValue[];
|
|
34
|
+
|
|
35
|
+
/** A block / collection / page `fields` bag — field name -> authored value. */
|
|
36
|
+
export interface FieldValues {
|
|
37
|
+
[field: string]: FieldValue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The JSON body of an editor RPC call: an object of values, any of which may be
|
|
41
|
+
* omitted (an absent key is simply not sent). */
|
|
42
|
+
export interface RpcInput {
|
|
43
|
+
[key: string]: FieldValue | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
5
46
|
export type FieldType =
|
|
6
47
|
| "text"
|
|
7
48
|
| "textarea"
|
|
@@ -25,7 +66,7 @@ export interface FieldDefinition {
|
|
|
25
66
|
label?: string;
|
|
26
67
|
type: FieldType;
|
|
27
68
|
required?: boolean;
|
|
28
|
-
default?:
|
|
69
|
+
default?: FieldValue;
|
|
29
70
|
fields?: FieldDefinition[];
|
|
30
71
|
min?: number;
|
|
31
72
|
max?: number;
|
|
@@ -47,7 +88,7 @@ export interface RegionDefinition {
|
|
|
47
88
|
export interface DefaultBlockDefinition {
|
|
48
89
|
region: string;
|
|
49
90
|
blockTypeSlug: string;
|
|
50
|
-
fields?:
|
|
91
|
+
fields?: FieldValues;
|
|
51
92
|
}
|
|
52
93
|
|
|
53
94
|
export interface BlockType {
|
|
@@ -85,8 +126,17 @@ export interface CollectionMeta {
|
|
|
85
126
|
* from this to open/save/delete it. */
|
|
86
127
|
idField: string;
|
|
87
128
|
orderBy?: { column: string; dir?: "asc" | "desc" };
|
|
129
|
+
/** The workflow features the collection opted into server-side (`supports`), e.g.
|
|
130
|
+
* `["drafts", "scheduling"]`. Carried here so this mirror stays faithful to
|
|
131
|
+
* `CollectionMeta`. The editor renders the matching affordances (publish/unpublish, a
|
|
132
|
+
* schedule picker, a preview link, a revision list) in `CollectionWorkflow`; an empty
|
|
133
|
+
* list renders none of them. */
|
|
134
|
+
supports?: CollectionFeature[];
|
|
88
135
|
}
|
|
89
136
|
|
|
137
|
+
/** Mirror of @pramen/cms `CollectionFeature`. */
|
|
138
|
+
export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
|
|
139
|
+
|
|
90
140
|
export interface Page {
|
|
91
141
|
id: string;
|
|
92
142
|
typeId: string;
|
|
@@ -94,7 +144,7 @@ export interface Page {
|
|
|
94
144
|
slug: string;
|
|
95
145
|
status: string;
|
|
96
146
|
locale: string;
|
|
97
|
-
fields?:
|
|
147
|
+
fields?: FieldValues | null; // content-type-level structured data (fieldsSchema)
|
|
98
148
|
translationGroupId?: string | null;
|
|
99
149
|
metaTitle?: string | null;
|
|
100
150
|
metaDescription?: string | null;
|
|
@@ -122,13 +172,13 @@ export interface RenderedBlock {
|
|
|
122
172
|
block_id: string; // block instance id (edit)
|
|
123
173
|
block_type: string;
|
|
124
174
|
title: string | null;
|
|
125
|
-
fields:
|
|
175
|
+
fields: FieldValues;
|
|
126
176
|
is_shared: boolean;
|
|
127
177
|
pending?: boolean; // optimistic placeholder — not yet persisted (temp ids, no getBlock)
|
|
128
178
|
}
|
|
129
179
|
|
|
130
180
|
export interface AssembledPage {
|
|
131
|
-
page: Page & { translations?: { locale: string; slug: string }[]; seo?:
|
|
181
|
+
page: Page & { translations?: { locale: string; slug: string }[]; seo?: FieldValues };
|
|
132
182
|
regions: Record<string, RenderedBlock[]>;
|
|
133
183
|
}
|
|
134
184
|
|