@pramen/cms-editor 0.0.59 → 0.0.60
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/README.md +8 -0
- package/dist/editor.css +1 -1
- package/dist/editor.js +166 -163
- package/package.json +1 -1
- package/src/api.ts +80 -2
- package/src/app-context.tsx +42 -3
- package/src/blockkit.tsx +410 -0
- package/src/buzola.gen.ts +117 -23
- package/src/chrome.ts +16 -0
- package/src/components.tsx +102 -10
- package/src/fields.tsx +257 -2
- package/src/furniture.tsx +924 -0
- package/src/nav.ts +134 -0
- package/src/routes/_layout.tsx +36 -54
- package/src/routes/admin-page.tsx +36 -0
- package/src/routes/block-type.tsx +29 -0
- package/src/routes/content-type.tsx +32 -0
- package/src/routes/menu.tsx +30 -0
- package/src/routes/menus.tsx +18 -0
- package/src/routes/page.tsx +1 -1
- package/src/routes/redirects.tsx +17 -0
- package/src/routes/schema.tsx +34 -0
- package/src/routes/taxonomies.tsx +18 -0
- package/src/routes/taxonomy.tsx +29 -0
- package/src/routes/widget-area.tsx +29 -0
- package/src/routes/widgets.tsx +18 -0
- package/src/schema-builder.tsx +944 -0
- package/src/types.ts +237 -1
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
// Authoring the SCHEMA behind pages: block types and content types.
|
|
2
|
+
//
|
|
3
|
+
// The editor could author pages and blocks but not the block types and content types those
|
|
4
|
+
// depend on, so a fresh CMS could not be bootstrapped from the editor at all — the empty
|
|
5
|
+
// state said as much ("Define block types + a content type first (via the API/admin)") and
|
|
6
|
+
// the only way through was to curl `createBlockType` / `createContentType`. The handlers
|
|
7
|
+
// were already there and already editor-gated; this is the surface over them (GitHub #9).
|
|
8
|
+
//
|
|
9
|
+
// It is the INVERSE of `fields.tsx`. That renders a `FieldDefinition[]` as a form to fill
|
|
10
|
+
// in; this edits the `FieldDefinition[]` itself. The two share the type list, so a field
|
|
11
|
+
// type that exists here is one `FieldForm` can render — `FIELD_TYPES` below is the mirror
|
|
12
|
+
// of the server's own list, which is what keeps that true.
|
|
13
|
+
|
|
14
|
+
import { Button, Heading, Input, Textarea } from "@podoba/react";
|
|
15
|
+
import { useEffect, useRef, useState } from "react";
|
|
16
|
+
import { useUnsavedGuard } from "./app-context";
|
|
17
|
+
import type { Api, BlockTypeInput, ContentTypeInput } from "./api";
|
|
18
|
+
import { CONTROL, slugify } from "./fields";
|
|
19
|
+
import { WRAP } from "./chrome";
|
|
20
|
+
import type { BlockType, ContentType, DefaultBlockDefinition, FieldDefinition, FieldType, RegionDefinition } from "./types";
|
|
21
|
+
|
|
22
|
+
/** Every field type the CMS knows — the editor's mirror of `FIELD_TYPES` in @pramen/cms.
|
|
23
|
+
*
|
|
24
|
+
* A mirror rather than an import: the editor is a standalone browser app with no
|
|
25
|
+
* server-package dependency. The server validates an authored schema against its own copy
|
|
26
|
+
* (`normalizeFieldSchema`), so a drift here is a 400 naming the type, not a silently stored
|
|
27
|
+
* field nothing renders. */
|
|
28
|
+
export const FIELD_TYPES: readonly FieldType[] = [
|
|
29
|
+
"text", "textarea", "richtext", "url", "number", "boolean", "date", "datetime",
|
|
30
|
+
"publish", "slug", "media", "select", "reference", "repeater", "group",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Mirror of `MAX_FIELD_DEPTH` in @pramen/cms. Enforced here too so the "+ Add field"
|
|
34
|
+
* button disappears at the limit rather than offering an edit the save will reject. */
|
|
35
|
+
const MAX_FIELD_DEPTH = 5;
|
|
36
|
+
|
|
37
|
+
/** Types that nest a further schema. */
|
|
38
|
+
const NESTING: readonly FieldType[] = ["group", "repeater"];
|
|
39
|
+
|
|
40
|
+
/** The text-ish types a `slug` may follow — same list the server checks. */
|
|
41
|
+
const SLUG_SOURCES: readonly FieldType[] = ["text", "textarea", "select", "url"];
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
/** A field name: an object key in a `fields` bag and a property name in generated TS. */
|
|
45
|
+
const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
46
|
+
|
|
47
|
+
/** Turn a label into a usable field name. Not `slugify` — that emits hyphens, which are
|
|
48
|
+
* legal in a URL segment and illegal in an object key you can write as `fields.heading`. */
|
|
49
|
+
function fieldNameFrom(label: string): string {
|
|
50
|
+
const parts = label
|
|
51
|
+
.normalize("NFD")
|
|
52
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
53
|
+
.replace(/[^A-Za-z0-9]+/g, " ")
|
|
54
|
+
.trim()
|
|
55
|
+
.split(" ")
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
if (parts.length === 0) return "";
|
|
58
|
+
const [first, ...rest] = parts;
|
|
59
|
+
const camel = first.toLowerCase() + rest.map((p) => p[0]!.toUpperCase() + p.slice(1).toLowerCase()).join("");
|
|
60
|
+
return FIELD_NAME.test(camel) ? camel : `f${camel}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- the field-schema editor ----------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Edit one `FieldDefinition[]`.
|
|
67
|
+
*
|
|
68
|
+
* Recursive, mirroring `FieldForm`: a `group`/`repeater` field renders another one of these
|
|
69
|
+
* for its own `fields`. `depth` exists only to stop offering "+ Add field" past the nesting
|
|
70
|
+
* cap the server enforces — a control that always produced a 400 would be worse than none.
|
|
71
|
+
*/
|
|
72
|
+
export function FieldSchemaEditor({ schema, onChange, depth = 0 }: { schema: FieldDefinition[]; onChange: (s: FieldDefinition[]) => void; depth?: number }) {
|
|
73
|
+
const set = (i: number, f: FieldDefinition) => onChange(schema.map((x, j) => (j === i ? f : x)));
|
|
74
|
+
const del = (i: number) => onChange(schema.filter((_, j) => j !== i));
|
|
75
|
+
const move = (i: number, d: number) => {
|
|
76
|
+
const to = i + d;
|
|
77
|
+
if (to < 0 || to >= schema.length) return;
|
|
78
|
+
const next = schema.slice();
|
|
79
|
+
const [moved] = next.splice(i, 1);
|
|
80
|
+
next.splice(to, 0, moved!);
|
|
81
|
+
onChange(next);
|
|
82
|
+
};
|
|
83
|
+
const add = () => {
|
|
84
|
+
// A unique placeholder name, so adding two fields in a row does not immediately trip the
|
|
85
|
+
// duplicate-name check with nothing typed yet.
|
|
86
|
+
let n = schema.length + 1;
|
|
87
|
+
while (schema.some((f) => f.name === `field${n}`)) n++;
|
|
88
|
+
onChange([...schema, { name: `field${n}`, type: "text" }]);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const names = schema.map((f) => f.name);
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex flex-col gap-2">
|
|
94
|
+
{schema.length === 0 ? <p className="text-sm text-fg-subtle">No fields yet.</p> : null}
|
|
95
|
+
{schema.map((f, i) => (
|
|
96
|
+
<FieldRow
|
|
97
|
+
key={i}
|
|
98
|
+
def={f}
|
|
99
|
+
siblings={names}
|
|
100
|
+
siblingFields={schema}
|
|
101
|
+
index={i}
|
|
102
|
+
count={schema.length}
|
|
103
|
+
depth={depth}
|
|
104
|
+
onChange={(next) => set(i, next)}
|
|
105
|
+
onMove={(d) => move(i, d)}
|
|
106
|
+
onDelete={() => del(i)}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
{depth + 1 < MAX_FIELD_DEPTH ? (
|
|
110
|
+
<Button variant="secondary" size="sm" className="self-start" onPress={add}>+ Add field</Button>
|
|
111
|
+
) : (
|
|
112
|
+
<p className="text-caption text-fg-subtle">Fields cannot nest deeper than {MAX_FIELD_DEPTH} levels.</p>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function FieldRow({ def, siblings, siblingFields, index, count, depth, onChange, onMove, onDelete }: {
|
|
119
|
+
def: FieldDefinition;
|
|
120
|
+
siblings: string[];
|
|
121
|
+
siblingFields: FieldDefinition[];
|
|
122
|
+
index: number;
|
|
123
|
+
count: number;
|
|
124
|
+
depth: number;
|
|
125
|
+
onChange: (f: FieldDefinition) => void;
|
|
126
|
+
onMove: (d: number) => void;
|
|
127
|
+
onDelete: () => void;
|
|
128
|
+
}) {
|
|
129
|
+
const patch = (p: Partial<FieldDefinition>) => onChange({ ...def, ...p });
|
|
130
|
+
const duplicate = siblings.filter((n) => n === def.name).length > 1;
|
|
131
|
+
const badName = def.name !== "" && !FIELD_NAME.test(def.name);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Switching a field's TYPE drops the keys the old type owned.
|
|
135
|
+
*
|
|
136
|
+
* Carrying them would store a `select`'s `options` on a field that is now `text`, which
|
|
137
|
+
* the server strips anyway — but worse, switching back would silently resurrect the old
|
|
138
|
+
* options after the author thought they were gone. `name`/`label`/`required` are the
|
|
139
|
+
* type-independent half and survive.
|
|
140
|
+
*/
|
|
141
|
+
const retype = (type: FieldType) => {
|
|
142
|
+
const next: FieldDefinition = { name: def.name, type };
|
|
143
|
+
if (def.label) next.label = def.label;
|
|
144
|
+
if (def.required) next.required = true;
|
|
145
|
+
if (NESTING.includes(type)) next.fields = NESTING.includes(def.type) ? def.fields ?? [] : [];
|
|
146
|
+
onChange(next);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="rounded-lg border border-border bg-surface-muted">
|
|
151
|
+
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
152
|
+
<span className="text-caption text-fg-subtle">{index + 1}</span>
|
|
153
|
+
<span className="min-w-0 flex-1 truncate text-sm text-fg">
|
|
154
|
+
{def.label || def.name} <span className="text-fg-subtle">· {def.type}</span>
|
|
155
|
+
</span>
|
|
156
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-fg disabled:opacity-30" title="Move up" disabled={index === 0} onClick={() => onMove(-1)}>↑</button>
|
|
157
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-fg disabled:opacity-30" title="Move down" disabled={index === count - 1} onClick={() => onMove(1)}>↓</button>
|
|
158
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-danger" title="Remove" onClick={onDelete}>✕</button>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="flex flex-col gap-3 p-3.5">
|
|
161
|
+
<div className="grid grid-cols-3 gap-3 max-[720px]:grid-cols-1">
|
|
162
|
+
<label className="flex flex-col gap-1.5">
|
|
163
|
+
<span className="text-caption text-fg-subtle">Label</span>
|
|
164
|
+
<input
|
|
165
|
+
className={CONTROL}
|
|
166
|
+
value={def.label ?? ""}
|
|
167
|
+
placeholder={def.name}
|
|
168
|
+
onChange={(e) => {
|
|
169
|
+
const label = e.target.value;
|
|
170
|
+
// The name follows the label only while it is still the untouched
|
|
171
|
+
// placeholder. A name that has been set is a stored key: renaming it orphans
|
|
172
|
+
// every value already written under the old one, which is exactly the
|
|
173
|
+
// "silently rewriting a slug" trap the slug control avoids.
|
|
174
|
+
const derived = /^field\d+$/.test(def.name) ? fieldNameFrom(label) : "";
|
|
175
|
+
patch(derived ? { label, name: derived } : { label });
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
</label>
|
|
179
|
+
<label className="flex flex-col gap-1.5">
|
|
180
|
+
<span className="text-caption text-fg-subtle">Name (the stored key)</span>
|
|
181
|
+
<input className={CONTROL} value={def.name} onChange={(e) => patch({ name: e.target.value.trim() })} />
|
|
182
|
+
</label>
|
|
183
|
+
<label className="flex flex-col gap-1.5">
|
|
184
|
+
<span className="text-caption text-fg-subtle">Type</span>
|
|
185
|
+
<select className={CONTROL} value={def.type} onChange={(e) => retype(e.target.value as FieldType)}>
|
|
186
|
+
{FIELD_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
187
|
+
</select>
|
|
188
|
+
</label>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{duplicate ? <p className="text-caption text-danger">Two fields here are called “{def.name}” — they would write the same key, and one could never be saved.</p> : null}
|
|
192
|
+
{badName ? <p className="text-caption text-danger">A name must start with a letter or underscore and hold only letters, digits and underscores.</p> : null}
|
|
193
|
+
|
|
194
|
+
<label className="flex items-center gap-2">
|
|
195
|
+
<input type="checkbox" checked={def.required === true} onChange={(e) => patch({ required: e.target.checked || undefined })} />
|
|
196
|
+
<span className="text-sm text-fg">Required</span>
|
|
197
|
+
</label>
|
|
198
|
+
|
|
199
|
+
{def.type === "select" ? <SelectExtras def={def} patch={patch} /> : null}
|
|
200
|
+
{def.type === "reference" ? <ReferenceExtras def={def} patch={patch} /> : null}
|
|
201
|
+
{def.type === "slug" ? <SlugExtras def={def} siblingFields={siblingFields} patch={patch} /> : null}
|
|
202
|
+
{def.type === "repeater" ? <RepeaterExtras def={def} patch={patch} /> : null}
|
|
203
|
+
|
|
204
|
+
{NESTING.includes(def.type) ? (
|
|
205
|
+
<div className="rounded-lg border border-border bg-surface-card p-3.5">
|
|
206
|
+
<p className="mb-2 text-caption text-fg-subtle">Nested fields</p>
|
|
207
|
+
<FieldSchemaEditor schema={def.fields ?? []} onChange={(fields) => patch({ fields })} depth={depth + 1} />
|
|
208
|
+
{(def.fields ?? []).length === 0 ? (
|
|
209
|
+
<p className="mt-2 text-caption text-danger">A {def.type} needs at least one nested field.</p>
|
|
210
|
+
) : null}
|
|
211
|
+
</div>
|
|
212
|
+
) : null}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function SelectExtras({ def, patch }: { def: FieldDefinition; patch: (p: Partial<FieldDefinition>) => void }) {
|
|
219
|
+
const usingHandler = Boolean(def.optionsFrom);
|
|
220
|
+
return (
|
|
221
|
+
<div className="flex flex-col gap-2">
|
|
222
|
+
<label className="flex items-center gap-2">
|
|
223
|
+
<input
|
|
224
|
+
type="checkbox"
|
|
225
|
+
checked={usingHandler}
|
|
226
|
+
onChange={(e) => patch(e.target.checked ? { optionsFrom: "", options: undefined } : { optionsFrom: undefined, options: [] })}
|
|
227
|
+
/>
|
|
228
|
+
<span className="text-sm text-fg">Fetch the options from a query handler</span>
|
|
229
|
+
</label>
|
|
230
|
+
{usingHandler ? (
|
|
231
|
+
<label className="flex flex-col gap-1.5">
|
|
232
|
+
<span className="text-caption text-fg-subtle">Handler name (returns <code>{"{ value, label }[]"}</code>)</span>
|
|
233
|
+
<input className={CONTROL} value={def.optionsFrom ?? ""} onChange={(e) => patch({ optionsFrom: e.target.value.trim() })} />
|
|
234
|
+
</label>
|
|
235
|
+
) : (
|
|
236
|
+
<OptionsTextarea options={def.options ?? []} onChange={(options) => patch({ options })} />
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The `select` options list, edited as one-per-line text.
|
|
244
|
+
*
|
|
245
|
+
* The text lives in LOCAL state and the parsed array goes upward. Deriving the textarea's
|
|
246
|
+
* value from the parsed array instead — `options.join("\n")` — made the field unusable:
|
|
247
|
+
* the parse trims and drops empties on every keystroke, so typing a space gave back the
|
|
248
|
+
* same array, the value prop never changed, and React restored the DOM. Space and Enter
|
|
249
|
+
* were erased as typed, which meant no multi-word option and no second option. A `select`
|
|
250
|
+
* could not be authored at all in the builder that introduces it.
|
|
251
|
+
*
|
|
252
|
+
* Re-seeded only when the incoming array is not the one this last emitted — i.e. the form
|
|
253
|
+
* switched to a different field, not our own change coming back around. Same rule the
|
|
254
|
+
* rich-text control uses, for the same reason.
|
|
255
|
+
*/
|
|
256
|
+
function OptionsTextarea({ options, onChange }: { options: readonly string[]; onChange: (v: string[]) => void }) {
|
|
257
|
+
const [text, setText] = useState(() => options.join("\n"));
|
|
258
|
+
const emitted = useRef<readonly string[] | null>(null);
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
if (options === emitted.current) return;
|
|
261
|
+
setText(options.join("\n"));
|
|
262
|
+
}, [options]);
|
|
263
|
+
return (
|
|
264
|
+
<label className="flex flex-col gap-1.5">
|
|
265
|
+
<span className="text-caption text-fg-subtle">Options, one per line</span>
|
|
266
|
+
<textarea
|
|
267
|
+
className={`${CONTROL} h-auto min-h-20 py-2.5`}
|
|
268
|
+
value={text}
|
|
269
|
+
onChange={(e) => {
|
|
270
|
+
setText(e.target.value);
|
|
271
|
+
const parsed = e.target.value.split("\n").map((v) => v.trim()).filter(Boolean);
|
|
272
|
+
emitted.current = parsed;
|
|
273
|
+
onChange(parsed);
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
</label>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function ReferenceExtras({ def, patch }: { def: FieldDefinition; patch: (p: Partial<FieldDefinition>) => void }) {
|
|
281
|
+
return (
|
|
282
|
+
<div className="flex flex-col gap-2">
|
|
283
|
+
<label className="flex flex-col gap-1.5">
|
|
284
|
+
<span className="text-caption text-fg-subtle">
|
|
285
|
+
Handler name — answers <code>{"{ search, limit, offset }"}</code> and <code>{"{ ids }"}</code>
|
|
286
|
+
</span>
|
|
287
|
+
<input className={CONTROL} value={def.referenceFrom ?? ""} onChange={(e) => patch({ referenceFrom: e.target.value.trim() })} />
|
|
288
|
+
</label>
|
|
289
|
+
<label className="flex items-center gap-2">
|
|
290
|
+
<input type="checkbox" checked={def.multiple === true} onChange={(e) => patch({ multiple: e.target.checked || undefined })} />
|
|
291
|
+
<span className="text-sm text-fg">Allow several</span>
|
|
292
|
+
</label>
|
|
293
|
+
{def.referenceFrom ? null : <p className="text-caption text-danger">A reference needs a handler to resolve it.</p>}
|
|
294
|
+
</div>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function SlugExtras({ def, siblingFields, patch }: { def: FieldDefinition; siblingFields: FieldDefinition[]; patch: (p: Partial<FieldDefinition>) => void }) {
|
|
299
|
+
const sources = siblingFields.filter((f) => f.name !== def.name && SLUG_SOURCES.includes(f.type));
|
|
300
|
+
return (
|
|
301
|
+
<label className="flex flex-col gap-1.5">
|
|
302
|
+
<span className="text-caption text-fg-subtle">Derived from (a text field beside it — optional)</span>
|
|
303
|
+
<select className={CONTROL} value={def.from ?? ""} onChange={(e) => patch({ from: e.target.value || undefined })}>
|
|
304
|
+
<option value="">— typed by hand —</option>
|
|
305
|
+
{/* Only fields the server will ACCEPT as a source. `SLUG_SOURCES` was declared for
|
|
306
|
+
this check and then used only in the hint below, so the dropdown offered every
|
|
307
|
+
sibling — including a `number` or a `media` — and picking one made the whole type
|
|
308
|
+
unsavable with an error naming a field the author had just been offered. */}
|
|
309
|
+
{sources.map((f) => <option key={f.name} value={f.name}>{f.label ?? f.name}</option>)}
|
|
310
|
+
</select>
|
|
311
|
+
<span className="text-caption text-fg-subtle">
|
|
312
|
+
{sources.length > 0 ? `The source must be a ${SLUG_SOURCES.join(" / ")} field.` : `No ${SLUG_SOURCES.join(" / ")} field stands beside this one yet.`}
|
|
313
|
+
</span>
|
|
314
|
+
</label>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function RepeaterExtras({ def, patch }: { def: FieldDefinition; patch: (p: Partial<FieldDefinition>) => void }) {
|
|
319
|
+
const num = (v: string) => (v === "" ? undefined : Math.max(0, Math.trunc(Number(v))));
|
|
320
|
+
return (
|
|
321
|
+
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
|
322
|
+
<label className="flex flex-col gap-1.5">
|
|
323
|
+
<span className="text-caption text-fg-subtle">Minimum items</span>
|
|
324
|
+
<input className={CONTROL} type="number" min={0} value={def.min ?? ""} onChange={(e) => patch({ min: num(e.target.value) })} />
|
|
325
|
+
</label>
|
|
326
|
+
<label className="flex flex-col gap-1.5">
|
|
327
|
+
<span className="text-caption text-fg-subtle">Maximum items</span>
|
|
328
|
+
<input className={CONTROL} type="number" min={1} value={def.max ?? ""} onChange={(e) => patch({ max: num(e.target.value) })} />
|
|
329
|
+
</label>
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- code-defined types ---------------------------------------------------------------
|
|
335
|
+
//
|
|
336
|
+
// A type declared with `defineBlockType` / `defineContentType` and reconciled by
|
|
337
|
+
// `cmsBootstrap` is flagged `managed`, and the builder shows it read-only. Both surfaces
|
|
338
|
+
// listed code-defined and editor-authored types identically before, so the obvious thing to
|
|
339
|
+
// do — open one, add a field, hit Save — returned 200 and was reverted at the next cold
|
|
340
|
+
// start, orphaning any content authored against the field (GitHub #48). The server now
|
|
341
|
+
// refuses that write; this is the half that stops an editor walking into it.
|
|
342
|
+
|
|
343
|
+
/** The marker on a code-defined row in the overview lists. */
|
|
344
|
+
function CodeBadge() {
|
|
345
|
+
return (
|
|
346
|
+
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-caption text-fg-subtle" title="Defined in code — read-only here">
|
|
347
|
+
code
|
|
348
|
+
</span>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Shown ABOVE a read-only builder, in place of the Save button.
|
|
353
|
+
*
|
|
354
|
+
* Above, not inside: it used to sit within the disabled `<fieldset>`, and `disabled` takes
|
|
355
|
+
* the whole subtree out of the tab order — so a screen-reader user tabbed from "← Types"
|
|
356
|
+
* straight past every control and never reached the one paragraph explaining why the screen
|
|
357
|
+
* was empty. It is also what the fieldset's `aria-describedby` points at. */
|
|
358
|
+
function ManagedNotice({ id, what, defineFn, slug, owner }: { id: string; what: string; defineFn: string; slug: string; owner?: string | null }) {
|
|
359
|
+
return (
|
|
360
|
+
<div id={id} className="mb-4 max-w-[860px] rounded-lg border border-border bg-surface-muted px-3.5 py-3 text-small text-fg-muted">
|
|
361
|
+
This {what} is <strong className="font-medium text-fg">defined in code</strong> —{" "}
|
|
362
|
+
<code className="text-fg">{defineFn}("{slug}", …)</code>, applied on every boot by{" "}
|
|
363
|
+
<code className="text-fg">cmsBootstrap</code>{owner && owner !== "cms" ? <> (owner <code className="text-fg">{owner}</code>)</> : null}.
|
|
364
|
+
It is read-only here: a change saved from this screen would be reverted at the next deploy or
|
|
365
|
+
cold start. Edit the declaration and redeploy.
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The read-only form wrapper. `disabled` on a fieldset disables every native control inside
|
|
371
|
+
* it, so the lock is one attribute rather than a prop threaded through the region / field /
|
|
372
|
+
* default-block editors — none of which would then be able to forget it.
|
|
373
|
+
*
|
|
374
|
+
* It needs the styling too. podoba's controls render their disabled look from
|
|
375
|
+
* `data-[disabled]`, which react-aria sets from its OWN `isDisabled` prop and never from an
|
|
376
|
+
* ancestor fieldset — so the inert form was pixel-identical to a live one, except for the two
|
|
377
|
+
* buttons that happen to carry a `:disabled` class and dimmed while their neighbours did not.
|
|
378
|
+
* An editor clicked into Name, typed, and no characters appeared. The wrapper carries the
|
|
379
|
+
* visual state for everything inside it. */
|
|
380
|
+
function ReadOnlyFieldset({ locked, describedBy, label, className, children }: {
|
|
381
|
+
locked: boolean;
|
|
382
|
+
describedBy?: string;
|
|
383
|
+
label: string;
|
|
384
|
+
className: string;
|
|
385
|
+
children: React.ReactNode;
|
|
386
|
+
}) {
|
|
387
|
+
return (
|
|
388
|
+
<fieldset
|
|
389
|
+
disabled={locked}
|
|
390
|
+
aria-describedby={locked ? describedBy : undefined}
|
|
391
|
+
className={`m-0 min-w-0 border-0 p-0 ${className} ${locked ? "select-none opacity-60 [&_*]:cursor-not-allowed" : ""}`}
|
|
392
|
+
>
|
|
393
|
+
{/* An unnamed `group` is what a screen reader announces otherwise. */}
|
|
394
|
+
<legend className="sr-only">{label}</legend>
|
|
395
|
+
{children}
|
|
396
|
+
</fieldset>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- the types overview ---------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
export function TypesOverview({ api, codeDefinedTypes, onOpenBlockType, onOpenContentType, onError }: {
|
|
403
|
+
api: Api;
|
|
404
|
+
/** `listCmsCapabilities().codeDefinedTypes` — see `CmsCapabilities`. False against an older
|
|
405
|
+
* server, where `managedBy` is absent on every row and means nothing. */
|
|
406
|
+
codeDefinedTypes: boolean;
|
|
407
|
+
onOpenBlockType: (slug: string) => void;
|
|
408
|
+
onOpenContentType: (slug: string) => void;
|
|
409
|
+
onError: (s: string) => void;
|
|
410
|
+
}) {
|
|
411
|
+
const [blockTypes, setBlockTypes] = useState<BlockType[] | null>(null);
|
|
412
|
+
const [contentTypes, setContentTypes] = useState<ContentType[] | null>(null);
|
|
413
|
+
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
let live = true;
|
|
416
|
+
api.listBlockTypes().then((r) => live && setBlockTypes(r)).catch((e: Error) => onError(String(e.message ?? e)));
|
|
417
|
+
api.listContentTypes().then((r) => live && setContentTypes(r)).catch((e: Error) => onError(String(e.message ?? e)));
|
|
418
|
+
return () => { live = false; };
|
|
419
|
+
}, [api, onError]);
|
|
420
|
+
|
|
421
|
+
return (
|
|
422
|
+
<div className={WRAP}>
|
|
423
|
+
<div className="mb-6 mt-6">
|
|
424
|
+
<h1 className="m-0 text-[40px] font-normal leading-[1.1] tracking-[-0.01em]">
|
|
425
|
+
<span className="block text-fg-subtle">The shape of</span>
|
|
426
|
+
<span className="block text-fg">this site</span>
|
|
427
|
+
</h1>
|
|
428
|
+
<p className="mt-3 max-w-[62ch] text-sm text-fg-muted">
|
|
429
|
+
A <strong className="font-medium text-fg">block type</strong> is a set of fields an editor fills in.
|
|
430
|
+
A <strong className="font-medium text-fg">content type</strong> is a kind of page: the regions it has, and which block types may go in each.
|
|
431
|
+
Nothing can be authored until there is one of each.
|
|
432
|
+
</p>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<TypeSection
|
|
436
|
+
title="Block types"
|
|
437
|
+
empty="No block types yet. A page is built from these, so start here."
|
|
438
|
+
rows={blockTypes}
|
|
439
|
+
newLabel="+ New block type"
|
|
440
|
+
onNew={() => onOpenBlockType("new")}
|
|
441
|
+
render={(bt) => (
|
|
442
|
+
<div className={"flex cursor-pointer items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5 hover:bg-surface-muted"} key={bt.id} onClick={() => onOpenBlockType(bt.slug)}>
|
|
443
|
+
<span className="w-6 shrink-0 text-center">{bt.icon ?? ""}</span>
|
|
444
|
+
<span className="min-w-0 flex-1 truncate font-medium">{bt.name}</span>
|
|
445
|
+
{codeDefinedTypes && bt.managedBy ? <CodeBadge /> : null}
|
|
446
|
+
<span className="shrink-0 truncate text-fg-subtle">{bt.slug}</span>
|
|
447
|
+
<span className="shrink-0 text-caption text-fg-subtle">{(bt.fieldsSchema ?? []).length} field(s)</span>
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
/>
|
|
451
|
+
|
|
452
|
+
<TypeSection
|
|
453
|
+
title="Content types"
|
|
454
|
+
empty="No content types yet. A page needs one — it is what declares the regions blocks go into."
|
|
455
|
+
rows={contentTypes}
|
|
456
|
+
newLabel="+ New content type"
|
|
457
|
+
onNew={() => onOpenContentType("new")}
|
|
458
|
+
render={(ct) => (
|
|
459
|
+
<div className={"flex cursor-pointer items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5 hover:bg-surface-muted"} key={ct.id} onClick={() => onOpenContentType(ct.slug)}>
|
|
460
|
+
<span className="min-w-0 flex-1 truncate font-medium">{ct.name}</span>
|
|
461
|
+
{codeDefinedTypes && ct.managedBy ? <CodeBadge /> : null}
|
|
462
|
+
<span className="shrink-0 truncate text-fg-subtle">{ct.slug}</span>
|
|
463
|
+
<span className="shrink-0 text-caption text-fg-subtle">{(ct.regions ?? []).length} region(s)</span>
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
/>
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function TypeSection<T>({ title, empty, rows, newLabel, onNew, render }: {
|
|
472
|
+
title: string;
|
|
473
|
+
empty: string;
|
|
474
|
+
rows: T[] | null;
|
|
475
|
+
newLabel: string;
|
|
476
|
+
onNew: () => void;
|
|
477
|
+
render: (row: T) => React.ReactNode;
|
|
478
|
+
}) {
|
|
479
|
+
return (
|
|
480
|
+
<section className="mb-8">
|
|
481
|
+
<div className="mb-2 flex items-center gap-3">
|
|
482
|
+
<Heading level="2" className="font-normal">{title}</Heading>
|
|
483
|
+
<Button variant="secondary" size="sm" onPress={onNew}>{newLabel}</Button>
|
|
484
|
+
</div>
|
|
485
|
+
{rows === null ? (
|
|
486
|
+
<p className="text-fg-subtle">Loading…</p>
|
|
487
|
+
) : rows.length === 0 ? (
|
|
488
|
+
<p className="text-fg-subtle">{empty}</p>
|
|
489
|
+
) : (
|
|
490
|
+
<div className="flex flex-col gap-2">{rows.map(render)}</div>
|
|
491
|
+
)}
|
|
492
|
+
</section>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// --- block-type editor ----------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/** The `aria-describedby` target linking a locked fieldset to its explanation. */
|
|
499
|
+
const NOTICE_ID = "cms-managed-notice";
|
|
500
|
+
|
|
501
|
+
/** Empty-string-to-null, for the optional text columns. */
|
|
502
|
+
const orNull = (s: string): string | null => (s.trim() === "" ? null : s.trim());
|
|
503
|
+
|
|
504
|
+
export function BlockTypeEditor({ api, codeDefinedTypes, slug, onSaved, onBack, onError }: {
|
|
505
|
+
api: Api;
|
|
506
|
+
/** See `TypesOverview`. */
|
|
507
|
+
codeDefinedTypes: boolean;
|
|
508
|
+
/** `"new"` creates; anything else loads that block type by slug. */
|
|
509
|
+
slug: string;
|
|
510
|
+
onSaved: (slug: string) => void;
|
|
511
|
+
onBack: () => void;
|
|
512
|
+
onError: (s: string) => void;
|
|
513
|
+
}) {
|
|
514
|
+
const isNew = slug === "new";
|
|
515
|
+
const [draft, setDraft] = useState<BlockTypeInput>({ name: "", slug: "", fieldsSchema: [] });
|
|
516
|
+
const [id, setId] = useState<string | null>(null);
|
|
517
|
+
const [owner, setOwner] = useState<string | null>(null);
|
|
518
|
+
const [loading, setLoading] = useState(!isNew);
|
|
519
|
+
const [missing, setMissing] = useState(false);
|
|
520
|
+
const [busy, setBusy] = useState(false);
|
|
521
|
+
const [ok, setOk] = useState(false);
|
|
522
|
+
// Whether the slug still follows the name. Only ever true for a NEW type: an existing
|
|
523
|
+
// slug is a registry key a front end maps to a component, and the server refuses to
|
|
524
|
+
// change it anyway.
|
|
525
|
+
const [slugFollows, setSlugFollows] = useState(isNew);
|
|
526
|
+
// The draft as it was loaded (or empty, for a new type). Dirty is a comparison against
|
|
527
|
+
// this rather than a flag every mutation has to remember to set.
|
|
528
|
+
const [baseline, setBaseline] = useState<string>(() => JSON.stringify({ name: "", slug: "", fieldsSchema: [] }));
|
|
529
|
+
useUnsavedGuard(JSON.stringify(draft) !== baseline);
|
|
530
|
+
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
if (isNew) return;
|
|
533
|
+
let live = true;
|
|
534
|
+
setLoading(true);
|
|
535
|
+
api
|
|
536
|
+
.listBlockTypes()
|
|
537
|
+
.then((all) => {
|
|
538
|
+
if (!live) return;
|
|
539
|
+
const bt = all.find((b) => b.slug === slug);
|
|
540
|
+
if (!bt) { setMissing(true); return; }
|
|
541
|
+
setId(bt.id);
|
|
542
|
+
setOwner(bt.managedBy ?? null);
|
|
543
|
+
const loaded: BlockTypeInput = {
|
|
544
|
+
name: bt.name,
|
|
545
|
+
slug: bt.slug,
|
|
546
|
+
description: bt.description ?? null,
|
|
547
|
+
icon: bt.icon ?? null,
|
|
548
|
+
category: bt.category ?? null,
|
|
549
|
+
fieldsSchema: bt.fieldsSchema ?? [],
|
|
550
|
+
};
|
|
551
|
+
setDraft(loaded);
|
|
552
|
+
setBaseline(JSON.stringify(loaded));
|
|
553
|
+
})
|
|
554
|
+
// `missing` as well as the error toast: without it the failed load fell through to an
|
|
555
|
+
// EDITABLE, un-badged, empty form for what may well be a code-defined type — a screen
|
|
556
|
+
// asserting the opposite of the truth, whose Save then 400s on a null id.
|
|
557
|
+
.catch((e: Error) => { if (live) setMissing(true); onError(String(e.message ?? e)); })
|
|
558
|
+
.finally(() => { if (live) setLoading(false); });
|
|
559
|
+
return () => { live = false; };
|
|
560
|
+
}, [api, slug, isNew, onError]);
|
|
561
|
+
|
|
562
|
+
const save = async () => {
|
|
563
|
+
setBusy(true);
|
|
564
|
+
try {
|
|
565
|
+
if (isNew) {
|
|
566
|
+
const created = await api.createBlockType(draft);
|
|
567
|
+
onSaved(created.slug);
|
|
568
|
+
} else {
|
|
569
|
+
// `slug` is deliberately not sent: it is the stable key, and the server ignores it
|
|
570
|
+
// on an update. Sending it would suggest to a reader that renaming works.
|
|
571
|
+
await api.updateBlockType(id!, { name: draft.name, description: draft.description, icon: draft.icon, category: draft.category, fieldsSchema: draft.fieldsSchema });
|
|
572
|
+
setBaseline(JSON.stringify(draft)); // saved — the guard stands down
|
|
573
|
+
setOk(true);
|
|
574
|
+
setTimeout(() => setOk(false), 1200);
|
|
575
|
+
}
|
|
576
|
+
} catch (e) {
|
|
577
|
+
onError(String((e as Error).message ?? e));
|
|
578
|
+
} finally {
|
|
579
|
+
setBusy(false);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
if (missing) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Unknown block type: {slug}</p></div>;
|
|
584
|
+
if (loading) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Loading…</p></div>;
|
|
585
|
+
|
|
586
|
+
// An owner means nothing on a server that does not declare the capability.
|
|
587
|
+
const locked = codeDefinedTypes && owner !== null;
|
|
588
|
+
|
|
589
|
+
return (
|
|
590
|
+
<div className={WRAP}>
|
|
591
|
+
<div className="mb-4 mt-2 flex items-center gap-3">
|
|
592
|
+
<Button variant="ghost" size="sm" onPress={onBack}>← Types</Button>
|
|
593
|
+
<h1 className="text-[22px] font-normal text-fg">{isNew ? "New block type" : draft.name}</h1>
|
|
594
|
+
{locked ? <CodeBadge /> : null}
|
|
595
|
+
</div>
|
|
596
|
+
{locked ? <ManagedNotice id={NOTICE_ID} what="block type" defineFn="defineBlockType" slug={draft.slug} owner={owner} /> : null}
|
|
597
|
+
<ReadOnlyFieldset locked={locked} describedBy={NOTICE_ID} label="Block type" className="flex max-w-[860px] flex-col gap-4">
|
|
598
|
+
{ok ? <div className="rounded-lg border border-brand-green bg-brand-green/20 px-3.5 py-2.5 text-small text-fg">saved</div> : null}
|
|
599
|
+
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
|
600
|
+
<Input
|
|
601
|
+
label="Name"
|
|
602
|
+
value={draft.name}
|
|
603
|
+
onChange={(name) => setDraft((d) => ({ ...d, name, ...(slugFollows ? { slug: slugify(name) } : {}) }))}
|
|
604
|
+
/>
|
|
605
|
+
<label className="flex flex-col gap-2">
|
|
606
|
+
<span className="text-sm font-medium text-fg">Slug {isNew ? null : <span className="text-fg-subtle">(fixed)</span>}</span>
|
|
607
|
+
<input
|
|
608
|
+
className={CONTROL}
|
|
609
|
+
value={draft.slug}
|
|
610
|
+
disabled={!isNew}
|
|
611
|
+
onChange={(e) => { setSlugFollows(false); setDraft((d) => ({ ...d, slug: e.target.value.trim() })); }}
|
|
612
|
+
/>
|
|
613
|
+
<span className="text-caption text-fg-subtle">
|
|
614
|
+
{isNew
|
|
615
|
+
? "The key a front end maps to a component. Lowercase letters, digits, hyphens or underscores."
|
|
616
|
+
: "A block type's slug is its registry key — renaming it would orphan every block of this type."}
|
|
617
|
+
</span>
|
|
618
|
+
</label>
|
|
619
|
+
</div>
|
|
620
|
+
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
|
621
|
+
<label className="flex flex-col gap-2">
|
|
622
|
+
<span className="text-sm font-medium text-fg">Icon</span>
|
|
623
|
+
<input className={CONTROL} value={draft.icon ?? ""} placeholder="e.g. 🖼" onChange={(e) => setDraft((d) => ({ ...d, icon: orNull(e.target.value) }))} />
|
|
624
|
+
</label>
|
|
625
|
+
<label className="flex flex-col gap-2">
|
|
626
|
+
<span className="text-sm font-medium text-fg">Category</span>
|
|
627
|
+
<input className={CONTROL} value={draft.category ?? ""} placeholder="e.g. Layout" onChange={(e) => setDraft((d) => ({ ...d, category: orNull(e.target.value) }))} />
|
|
628
|
+
</label>
|
|
629
|
+
</div>
|
|
630
|
+
<Textarea label="Description" value={draft.description ?? ""} onChange={(v) => setDraft((d) => ({ ...d, description: orNull(v) }))} />
|
|
631
|
+
|
|
632
|
+
<div>
|
|
633
|
+
<Heading level="2" className="mb-2 font-normal">Fields</Heading>
|
|
634
|
+
<p className="mb-3 max-w-[62ch] text-caption text-fg-subtle">
|
|
635
|
+
These are what an editor fills in for every block of this type. Removing one leaves the values already
|
|
636
|
+
written under its name in the store, but nothing will render or edit them.
|
|
637
|
+
</p>
|
|
638
|
+
<FieldSchemaEditor schema={draft.fieldsSchema ?? []} onChange={(fieldsSchema) => setDraft((d) => ({ ...d, fieldsSchema }))} />
|
|
639
|
+
</div>
|
|
640
|
+
|
|
641
|
+
{locked ? null : (
|
|
642
|
+
<div className="mt-2">
|
|
643
|
+
<Button onPress={save} isDisabled={busy || draft.name.trim() === "" || draft.slug.trim() === ""}>
|
|
644
|
+
{busy ? "Saving…" : isNew ? "Create" : "Save"}
|
|
645
|
+
</Button>
|
|
646
|
+
</div>
|
|
647
|
+
)}
|
|
648
|
+
</ReadOnlyFieldset>
|
|
649
|
+
</div>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// --- content-type editor --------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
export function ContentTypeEditor({ api, codeDefinedTypes, slug, onSaved, onBack, onError }: {
|
|
656
|
+
api: Api;
|
|
657
|
+
/** See `TypesOverview`. */
|
|
658
|
+
codeDefinedTypes: boolean;
|
|
659
|
+
slug: string;
|
|
660
|
+
onSaved: (slug: string) => void;
|
|
661
|
+
onBack: () => void;
|
|
662
|
+
onError: (s: string) => void;
|
|
663
|
+
}) {
|
|
664
|
+
const isNew = slug === "new";
|
|
665
|
+
const [draft, setDraft] = useState<ContentTypeInput>({ name: "", slug: "", regions: [{ name: "content", label: "Content", allowedTypes: null }], fieldsSchema: [], defaultBlocks: [] });
|
|
666
|
+
const [id, setId] = useState<string | null>(null);
|
|
667
|
+
const [owner, setOwner] = useState<string | null>(null);
|
|
668
|
+
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
669
|
+
const [loading, setLoading] = useState(!isNew);
|
|
670
|
+
const [missing, setMissing] = useState(false);
|
|
671
|
+
const [busy, setBusy] = useState(false);
|
|
672
|
+
const [ok, setOk] = useState(false);
|
|
673
|
+
const [slugFollows, setSlugFollows] = useState(isNew);
|
|
674
|
+
const [baseline, setBaseline] = useState<string>(() => JSON.stringify({ name: "", slug: "", regions: [{ name: "content", label: "Content", allowedTypes: null }], fieldsSchema: [], defaultBlocks: [] }));
|
|
675
|
+
useUnsavedGuard(JSON.stringify(draft) !== baseline);
|
|
676
|
+
|
|
677
|
+
useEffect(() => {
|
|
678
|
+
let live = true;
|
|
679
|
+
api.listBlockTypes().then((r) => live && setBlockTypes(r)).catch(() => setBlockTypes([]));
|
|
680
|
+
return () => { live = false; };
|
|
681
|
+
}, [api]);
|
|
682
|
+
|
|
683
|
+
useEffect(() => {
|
|
684
|
+
if (isNew) return;
|
|
685
|
+
let live = true;
|
|
686
|
+
setLoading(true);
|
|
687
|
+
api
|
|
688
|
+
.listContentTypes()
|
|
689
|
+
.then((all) => {
|
|
690
|
+
if (!live) return;
|
|
691
|
+
const ct = all.find((c) => c.slug === slug);
|
|
692
|
+
if (!ct) { setMissing(true); return; }
|
|
693
|
+
setId(ct.id);
|
|
694
|
+
setOwner(ct.managedBy ?? null);
|
|
695
|
+
const loaded: ContentTypeInput = {
|
|
696
|
+
name: ct.name,
|
|
697
|
+
slug: ct.slug,
|
|
698
|
+
regions: ct.regions ?? [],
|
|
699
|
+
fieldsSchema: ct.fieldsSchema ?? [],
|
|
700
|
+
defaultBlocks: ct.defaultBlocks ?? [],
|
|
701
|
+
};
|
|
702
|
+
setDraft(loaded);
|
|
703
|
+
setBaseline(JSON.stringify(loaded));
|
|
704
|
+
})
|
|
705
|
+
// See the block-type builder: a failed load must not render an editable empty form.
|
|
706
|
+
.catch((e: Error) => { if (live) setMissing(true); onError(String(e.message ?? e)); })
|
|
707
|
+
.finally(() => { if (live) setLoading(false); });
|
|
708
|
+
return () => { live = false; };
|
|
709
|
+
}, [api, slug, isNew, onError]);
|
|
710
|
+
|
|
711
|
+
const save = async () => {
|
|
712
|
+
// A rename leaves default blocks pointing at the old name. Reconciled HERE, once, rather
|
|
713
|
+
// than on every keystroke: the server refuses an unmatched region, so this is the last
|
|
714
|
+
// moment it can be fixed without the author losing work they can still see on screen.
|
|
715
|
+
const orphaned = (draft.defaultBlocks ?? []).filter((b) => !regions.some((r) => r.name === b.region));
|
|
716
|
+
if (orphaned.length > 0) {
|
|
717
|
+
const names = [...new Set(orphaned.map((b) => b.region))].join(", ");
|
|
718
|
+
if (!confirm(`${orphaned.length} default block(s) point at a region that no longer exists (${names}). Remove them and save?`)) return;
|
|
719
|
+
setDraft((d) => ({ ...d, defaultBlocks: (d.defaultBlocks ?? []).filter((b) => regions.some((r) => r.name === b.region)) }));
|
|
720
|
+
return; // The author saves again against the cleaned draft — nothing is dropped unseen.
|
|
721
|
+
}
|
|
722
|
+
setBusy(true);
|
|
723
|
+
try {
|
|
724
|
+
if (isNew) {
|
|
725
|
+
const created = await api.createContentType(draft);
|
|
726
|
+
onSaved(created.slug);
|
|
727
|
+
} else {
|
|
728
|
+
await api.updateContentType(id!, { name: draft.name, regions: draft.regions, fieldsSchema: draft.fieldsSchema, defaultBlocks: draft.defaultBlocks });
|
|
729
|
+
setBaseline(JSON.stringify(draft));
|
|
730
|
+
setOk(true);
|
|
731
|
+
setTimeout(() => setOk(false), 1200);
|
|
732
|
+
}
|
|
733
|
+
} catch (e) {
|
|
734
|
+
onError(String((e as Error).message ?? e));
|
|
735
|
+
} finally {
|
|
736
|
+
setBusy(false);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
if (missing) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Unknown content type: {slug}</p></div>;
|
|
741
|
+
if (loading) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Loading…</p></div>;
|
|
742
|
+
|
|
743
|
+
const regions = draft.regions ?? [];
|
|
744
|
+
const locked = codeDefinedTypes && owner !== null;
|
|
745
|
+
return (
|
|
746
|
+
<div className={WRAP}>
|
|
747
|
+
<div className="mb-4 mt-2 flex items-center gap-3">
|
|
748
|
+
<Button variant="ghost" size="sm" onPress={onBack}>← Types</Button>
|
|
749
|
+
<h1 className="text-[22px] font-normal text-fg">{isNew ? "New content type" : draft.name}</h1>
|
|
750
|
+
{locked ? <CodeBadge /> : null}
|
|
751
|
+
</div>
|
|
752
|
+
{locked ? <ManagedNotice id={NOTICE_ID} what="content type" defineFn="defineContentType" slug={draft.slug} owner={owner} /> : null}
|
|
753
|
+
<ReadOnlyFieldset locked={locked} describedBy={NOTICE_ID} label="Content type" className="flex max-w-[860px] flex-col gap-5">
|
|
754
|
+
{ok ? <div className="rounded-lg border border-brand-green bg-brand-green/20 px-3.5 py-2.5 text-small text-fg">saved</div> : null}
|
|
755
|
+
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
|
756
|
+
<Input
|
|
757
|
+
label="Name"
|
|
758
|
+
value={draft.name}
|
|
759
|
+
onChange={(name) => setDraft((d) => ({ ...d, name, ...(slugFollows ? { slug: slugify(name) } : {}) }))}
|
|
760
|
+
/>
|
|
761
|
+
<label className="flex flex-col gap-2">
|
|
762
|
+
<span className="text-sm font-medium text-fg">Slug {isNew ? null : <span className="text-fg-subtle">(fixed)</span>}</span>
|
|
763
|
+
<input
|
|
764
|
+
className={CONTROL}
|
|
765
|
+
value={draft.slug}
|
|
766
|
+
disabled={!isNew}
|
|
767
|
+
onChange={(e) => { setSlugFollows(false); setDraft((d) => ({ ...d, slug: e.target.value.trim() })); }}
|
|
768
|
+
/>
|
|
769
|
+
<span className="text-caption text-fg-subtle">
|
|
770
|
+
{isNew ? "A URL segment: lowercase letters, digits and single hyphens." : "This slug addresses the type's own page list — renaming it would break every link to it."}
|
|
771
|
+
</span>
|
|
772
|
+
</label>
|
|
773
|
+
</div>
|
|
774
|
+
|
|
775
|
+
<div>
|
|
776
|
+
<Heading level="2" className="mb-2 font-normal">Regions</Heading>
|
|
777
|
+
<p className="mb-3 max-w-[62ch] text-caption text-fg-subtle">
|
|
778
|
+
A region is a named slot on the page. The allow-list decides what may be placed there; leave it empty for “any block type”.
|
|
779
|
+
</p>
|
|
780
|
+
<RegionsEditor
|
|
781
|
+
regions={regions}
|
|
782
|
+
blockTypes={blockTypes}
|
|
783
|
+
onChange={(next) => setDraft((d) => ({ ...d, regions: next }))}
|
|
784
|
+
// Pruning happens on REMOVE only, never on an arbitrary change. It used to run
|
|
785
|
+
// on every `onChange` — and the region-name input fires that per keystroke, so
|
|
786
|
+
// typing the first character of a rename made every default block in that region
|
|
787
|
+
// point at a name that no longer existed and deleted them all. They never came
|
|
788
|
+
// back when the rename finished, and saving persisted the loss with no warning.
|
|
789
|
+
onRegionRemoved={(name) => setDraft((d) => ({
|
|
790
|
+
...d,
|
|
791
|
+
defaultBlocks: (d.defaultBlocks ?? []).filter((b) => b.region !== name),
|
|
792
|
+
}))}
|
|
793
|
+
/>
|
|
794
|
+
</div>
|
|
795
|
+
|
|
796
|
+
<div>
|
|
797
|
+
<Heading level="2" className="mb-2 font-normal">Page fields</Heading>
|
|
798
|
+
<p className="mb-3 max-w-[62ch] text-caption text-fg-subtle">
|
|
799
|
+
Structured data on the page itself, beside its blocks — a lead image, a byline, a category.
|
|
800
|
+
</p>
|
|
801
|
+
<FieldSchemaEditor schema={draft.fieldsSchema ?? []} onChange={(fieldsSchema) => setDraft((d) => ({ ...d, fieldsSchema }))} />
|
|
802
|
+
</div>
|
|
803
|
+
|
|
804
|
+
<div>
|
|
805
|
+
<Heading level="2" className="mb-2 font-normal">Default blocks</Heading>
|
|
806
|
+
<p className="mb-3 max-w-[62ch] text-caption text-fg-subtle">
|
|
807
|
+
Created automatically in a region when a page of this type is created.
|
|
808
|
+
</p>
|
|
809
|
+
<DefaultBlocksEditor
|
|
810
|
+
blocks={draft.defaultBlocks ?? []}
|
|
811
|
+
regions={regions}
|
|
812
|
+
blockTypes={blockTypes}
|
|
813
|
+
onChange={(defaultBlocks) => setDraft((d) => ({ ...d, defaultBlocks }))}
|
|
814
|
+
/>
|
|
815
|
+
</div>
|
|
816
|
+
|
|
817
|
+
{locked ? null : (
|
|
818
|
+
<div>
|
|
819
|
+
<Button onPress={save} isDisabled={busy || draft.name.trim() === "" || draft.slug.trim() === "" || regions.length === 0}>
|
|
820
|
+
{busy ? "Saving…" : isNew ? "Create" : "Save"}
|
|
821
|
+
</Button>
|
|
822
|
+
{regions.length === 0 ? <p className="mt-2 text-caption text-danger">A content type needs at least one region.</p> : null}
|
|
823
|
+
</div>
|
|
824
|
+
)}
|
|
825
|
+
</ReadOnlyFieldset>
|
|
826
|
+
</div>
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function RegionsEditor({ regions, blockTypes, onChange, onRegionRemoved }: {
|
|
831
|
+
regions: RegionDefinition[];
|
|
832
|
+
blockTypes: BlockType[];
|
|
833
|
+
onChange: (r: RegionDefinition[]) => void;
|
|
834
|
+
/** Fired when a region is DELETED, so the caller can drop its default blocks. Deliberately
|
|
835
|
+
* separate from `onChange`: a rename is not a removal, and treating it as one is what
|
|
836
|
+
* deleted an author's default blocks one keystroke into editing a name. */
|
|
837
|
+
onRegionRemoved: (name: string) => void;
|
|
838
|
+
}) {
|
|
839
|
+
const set = (i: number, r: RegionDefinition) => onChange(regions.map((x, j) => (j === i ? r : x)));
|
|
840
|
+
const add = () => {
|
|
841
|
+
let n = regions.length + 1;
|
|
842
|
+
while (regions.some((r) => r.name === `region${n}`)) n++;
|
|
843
|
+
onChange([...regions, { name: `region${n}`, allowedTypes: null }]);
|
|
844
|
+
};
|
|
845
|
+
const toggle = (i: number, slug: string) => {
|
|
846
|
+
const r = regions[i]!;
|
|
847
|
+
const current = r.allowedTypes ?? [];
|
|
848
|
+
const next = current.includes(slug) ? current.filter((s) => s !== slug) : [...current, slug];
|
|
849
|
+
// An EMPTY allow-list means "none", which is a region nothing can go in. The server
|
|
850
|
+
// normalizes that to "any" rather than storing it; matching here keeps the checkbox
|
|
851
|
+
// state honest instead of showing all-unchecked and saving as all-allowed.
|
|
852
|
+
set(i, { ...r, allowedTypes: next.length > 0 ? next : null });
|
|
853
|
+
};
|
|
854
|
+
return (
|
|
855
|
+
<div className="flex flex-col gap-2">
|
|
856
|
+
{regions.map((r, i) => (
|
|
857
|
+
<div key={i} className="rounded-lg border border-border bg-surface-muted p-3.5">
|
|
858
|
+
<div className="mb-3 grid grid-cols-[1fr_1fr_auto] items-end gap-3 max-[720px]:grid-cols-1">
|
|
859
|
+
<label className="flex flex-col gap-1.5">
|
|
860
|
+
<span className="text-caption text-fg-subtle">Name (the key blocks are placed under)</span>
|
|
861
|
+
<input className={CONTROL} value={r.name} onChange={(e) => set(i, { ...r, name: e.target.value.trim() })} />
|
|
862
|
+
</label>
|
|
863
|
+
<label className="flex flex-col gap-1.5">
|
|
864
|
+
<span className="text-caption text-fg-subtle">Label</span>
|
|
865
|
+
<input className={CONTROL} value={r.label ?? ""} placeholder={r.name} onChange={(e) => set(i, { ...r, label: e.target.value || undefined })} />
|
|
866
|
+
</label>
|
|
867
|
+
<button
|
|
868
|
+
type="button"
|
|
869
|
+
className="px-2 py-2 text-fg-subtle hover:text-danger"
|
|
870
|
+
title="Remove region"
|
|
871
|
+
onClick={() => { onChange(regions.filter((_, j) => j !== i)); onRegionRemoved(r.name); }}
|
|
872
|
+
>✕</button>
|
|
873
|
+
</div>
|
|
874
|
+
<p className="mb-1.5 text-caption text-fg-subtle">
|
|
875
|
+
Allowed block types {r.allowedTypes === null || r.allowedTypes === undefined ? <span className="text-fg">— any</span> : null}
|
|
876
|
+
</p>
|
|
877
|
+
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
|
878
|
+
{blockTypes.length === 0 ? <span className="text-caption text-fg-subtle">No block types defined yet.</span> : null}
|
|
879
|
+
{blockTypes.map((bt) => (
|
|
880
|
+
<label key={bt.slug} className="flex items-center gap-1.5">
|
|
881
|
+
<input type="checkbox" checked={(r.allowedTypes ?? []).includes(bt.slug)} onChange={() => toggle(i, bt.slug)} />
|
|
882
|
+
<span className="text-sm text-fg">{bt.name}</span>
|
|
883
|
+
</label>
|
|
884
|
+
))}
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
))}
|
|
888
|
+
<Button variant="secondary" size="sm" className="self-start" onPress={add}>+ Add region</Button>
|
|
889
|
+
</div>
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function DefaultBlocksEditor({ blocks, regions, blockTypes, onChange }: {
|
|
894
|
+
blocks: DefaultBlockDefinition[];
|
|
895
|
+
regions: RegionDefinition[];
|
|
896
|
+
blockTypes: BlockType[];
|
|
897
|
+
onChange: (b: DefaultBlockDefinition[]) => void;
|
|
898
|
+
}) {
|
|
899
|
+
const set = (i: number, b: DefaultBlockDefinition) => onChange(blocks.map((x, j) => (j === i ? b : x)));
|
|
900
|
+
const allowedIn = (region: string): BlockType[] => {
|
|
901
|
+
const allowed = regions.find((r) => r.name === region)?.allowedTypes;
|
|
902
|
+
return allowed ? blockTypes.filter((bt) => allowed.includes(bt.slug)) : blockTypes;
|
|
903
|
+
};
|
|
904
|
+
const add = () => {
|
|
905
|
+
const region = regions[0]?.name ?? "";
|
|
906
|
+
onChange([...blocks, { region, blockTypeSlug: allowedIn(region)[0]?.slug ?? "" }]);
|
|
907
|
+
};
|
|
908
|
+
return (
|
|
909
|
+
<div className="flex flex-col gap-2">
|
|
910
|
+
{blocks.map((b, i) => {
|
|
911
|
+
const options = allowedIn(b.region);
|
|
912
|
+
return (
|
|
913
|
+
<div key={i} className="grid grid-cols-[1fr_1fr_auto] items-end gap-3 rounded-lg border border-border bg-surface-muted p-3.5 max-[720px]:grid-cols-1">
|
|
914
|
+
<label className="flex flex-col gap-1.5">
|
|
915
|
+
<span className="text-caption text-fg-subtle">Region</span>
|
|
916
|
+
<select
|
|
917
|
+
className={CONTROL}
|
|
918
|
+
value={b.region}
|
|
919
|
+
onChange={(e) => {
|
|
920
|
+
const region = e.target.value;
|
|
921
|
+
// Moving to a region whose allow-list excludes the current block type
|
|
922
|
+
// would be refused on save, so the type is re-picked here — the author's
|
|
923
|
+
// intent is "this region", not "this pair".
|
|
924
|
+
const still = allowedIn(region).some((bt) => bt.slug === b.blockTypeSlug);
|
|
925
|
+
set(i, { ...b, region, blockTypeSlug: still ? b.blockTypeSlug : allowedIn(region)[0]?.slug ?? "" });
|
|
926
|
+
}}
|
|
927
|
+
>
|
|
928
|
+
{regions.map((r) => <option key={r.name} value={r.name}>{r.label ?? r.name}</option>)}
|
|
929
|
+
</select>
|
|
930
|
+
</label>
|
|
931
|
+
<label className="flex flex-col gap-1.5">
|
|
932
|
+
<span className="text-caption text-fg-subtle">Block type</span>
|
|
933
|
+
<select className={CONTROL} value={b.blockTypeSlug} onChange={(e) => set(i, { ...b, blockTypeSlug: e.target.value })}>
|
|
934
|
+
{options.map((bt) => <option key={bt.slug} value={bt.slug}>{bt.name}</option>)}
|
|
935
|
+
</select>
|
|
936
|
+
</label>
|
|
937
|
+
<button type="button" className="px-2 py-2 text-fg-subtle hover:text-danger" title="Remove" onClick={() => onChange(blocks.filter((_, j) => j !== i))}>✕</button>
|
|
938
|
+
</div>
|
|
939
|
+
);
|
|
940
|
+
})}
|
|
941
|
+
<Button variant="secondary" size="sm" className="self-start" onPress={add} isDisabled={regions.length === 0 || blockTypes.length === 0}>+ Add default block</Button>
|
|
942
|
+
</div>
|
|
943
|
+
);
|
|
944
|
+
}
|