@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,924 @@
|
|
|
1
|
+
// Site furniture: menus, redirects, taxonomies and widget areas.
|
|
2
|
+
//
|
|
3
|
+
// The WordPress-parity surface every client project reinvented by hand (GitHub #32). All
|
|
4
|
+
// four are SITE-level rather than page-level — they exist once per deployment and are read
|
|
5
|
+
// by the layout — so none of them lives under Pages, and each gets its own nav entry
|
|
6
|
+
// positioned by `NAV_ORDER`.
|
|
7
|
+
//
|
|
8
|
+
// The screens share a shape: a list with an inline "new" form, and a detail editor for the
|
|
9
|
+
// one thing that is actually a document (a menu tree, a term hierarchy, a widget list).
|
|
10
|
+
// Redirects are the exception and stay a single table, because a redirect IS a row.
|
|
11
|
+
|
|
12
|
+
import { Button, Heading, Input } from "@podoba/react";
|
|
13
|
+
import { useCallback, useEffect, useState } from "react";
|
|
14
|
+
import { useUnsavedGuard } from "./app-context";
|
|
15
|
+
import type { Api } from "./api";
|
|
16
|
+
import { CONTROL, RichText, slugify } from "./fields";
|
|
17
|
+
import { ROW, WRAP } from "./chrome";
|
|
18
|
+
import type { CollectionMeta, Menu, MenuItem, MenuItemKind, Page, Redirect, RichTextDoc, Taxonomy, Term, Widget, WidgetArea } from "./types";
|
|
19
|
+
import { MAX_MENU_DEPTH, REDIRECT_STATUSES } from "./types";
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export function errText(e: unknown): string {
|
|
23
|
+
return String((e as Error)?.message ?? e);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Head({ lead, em, children }: { lead: string; em: string; children?: React.ReactNode }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="mb-6 mt-6 flex items-end justify-between gap-6 max-[820px]:flex-col max-[820px]:items-start">
|
|
29
|
+
<h1 className="m-0 text-[40px] font-normal leading-[1.1] tracking-[-0.01em]">
|
|
30
|
+
<span className="block text-fg-subtle">{lead}</span>
|
|
31
|
+
<span className="block text-fg">{em}</span>
|
|
32
|
+
</h1>
|
|
33
|
+
{children}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Saved() {
|
|
39
|
+
return <div className="rounded-lg border border-brand-green bg-brand-green/20 px-3.5 py-2.5 text-small text-fg">saved</div>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A `name`/`slug` key field with the same rule the server enforces, following a label
|
|
43
|
+
* while it is untouched. Used by all four screens' "new" forms. */
|
|
44
|
+
function KeyFields({ label, keyValue, onLabel, onKey, keyHint }: {
|
|
45
|
+
label: string;
|
|
46
|
+
keyValue: string;
|
|
47
|
+
onLabel: (v: string) => void;
|
|
48
|
+
onKey: (v: string) => void;
|
|
49
|
+
keyHint: string;
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
|
53
|
+
<Input label="Label" value={label} onChange={onLabel} />
|
|
54
|
+
<label className="flex flex-col gap-2">
|
|
55
|
+
<span className="text-sm font-medium text-fg">Key</span>
|
|
56
|
+
<input className={CONTROL} value={keyValue} onChange={(e) => onKey(e.target.value.trim())} />
|
|
57
|
+
<span className="text-caption text-fg-subtle">{keyHint}</span>
|
|
58
|
+
</label>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- menus ----------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export function MenusView({ api, onOpen, onError, canEdit }: { api: Api; onOpen: (name: string) => void; onError: (s: string) => void; canEdit: boolean }) {
|
|
66
|
+
const [menus, setMenus] = useState<Menu[] | null>(null);
|
|
67
|
+
const [label, setLabel] = useState("");
|
|
68
|
+
const [name, setName] = useState("");
|
|
69
|
+
const [nameTouched, setNameTouched] = useState(false);
|
|
70
|
+
const [busy, setBusy] = useState(false);
|
|
71
|
+
|
|
72
|
+
const refresh = useCallback(() => {
|
|
73
|
+
api.listMenus().then(setMenus).catch((e) => { setMenus([]); onError(errText(e)); });
|
|
74
|
+
}, [api, onError]);
|
|
75
|
+
useEffect(refresh, [refresh]);
|
|
76
|
+
|
|
77
|
+
const create = async () => {
|
|
78
|
+
setBusy(true);
|
|
79
|
+
try {
|
|
80
|
+
const created = await api.createMenu(name, label);
|
|
81
|
+
setLabel(""); setName(""); setNameTouched(false);
|
|
82
|
+
onOpen(created.name);
|
|
83
|
+
} catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className={WRAP}>
|
|
88
|
+
<Head lead="Navigation" em={menus === null ? "Menus" : menus.length === 1 ? "1 menu" : `${menus.length} menus`} />
|
|
89
|
+
<div className="flex flex-col gap-2">
|
|
90
|
+
{menus === null ? <p className="text-fg-subtle">Loading…</p> : null}
|
|
91
|
+
{menus?.length === 0 ? <p className="text-fg-subtle">No menus yet. A menu is read by name — <code>getMenu("primary")</code> — from your layout.</p> : null}
|
|
92
|
+
{(menus ?? []).map((m) => (
|
|
93
|
+
<div key={m.id} className={`${ROW} cursor-pointer hover:bg-surface-muted`} onClick={() => onOpen(m.name)}>
|
|
94
|
+
<span className="min-w-0 flex-1 truncate font-medium">{m.label}</span>
|
|
95
|
+
<span className="shrink-0 truncate text-fg-subtle">{m.name}</span>
|
|
96
|
+
<span className="shrink-0 text-caption text-fg-subtle">{countItems(m.items ?? [])} item(s)</span>
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
{canEdit ? (
|
|
101
|
+
<div className="mt-6 max-w-[720px] rounded-lg border border-border bg-surface-muted p-4">
|
|
102
|
+
<Heading level="2" className="mb-3 font-normal">New menu</Heading>
|
|
103
|
+
<KeyFields
|
|
104
|
+
label={label}
|
|
105
|
+
keyValue={name}
|
|
106
|
+
onLabel={(v) => { setLabel(v); if (!nameTouched) setName(slugify(v)); }}
|
|
107
|
+
onKey={(v) => { setNameTouched(true); setName(v); }}
|
|
108
|
+
keyHint="What your layout asks for. Not renameable afterwards."
|
|
109
|
+
/>
|
|
110
|
+
<Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !name.trim()}>Create menu</Button>
|
|
111
|
+
</div>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function countItems(items: readonly MenuItem[]): number {
|
|
118
|
+
return items.reduce((n, it) => n + 1 + countItems(it.children ?? []), 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A flattened view of a menu tree: every item with its depth and its path of indices.
|
|
122
|
+
* Editing operates on the PATH, so a move is one splice at a known place rather than a
|
|
123
|
+
* recursive rebuild that has to re-find the item it just moved. */
|
|
124
|
+
interface FlatItem { item: MenuItem; path: number[]; depth: number }
|
|
125
|
+
|
|
126
|
+
function flatten(items: readonly MenuItem[], prefix: number[] = []): FlatItem[] {
|
|
127
|
+
const out: FlatItem[] = [];
|
|
128
|
+
items.forEach((item, i) => {
|
|
129
|
+
const path = [...prefix, i];
|
|
130
|
+
out.push({ item, path, depth: prefix.length });
|
|
131
|
+
out.push(...flatten(item.children ?? [], path));
|
|
132
|
+
});
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The sibling list a path points into, and the index within it. */
|
|
137
|
+
function siblingsAt(items: MenuItem[], path: readonly number[]): { list: MenuItem[]; index: number } {
|
|
138
|
+
let list = items;
|
|
139
|
+
for (let i = 0; i < path.length - 1; i++) list = list[path[i]!]!.children ?? [];
|
|
140
|
+
return { list, index: path[path.length - 1]! };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Structural edits, all as "clone the tree, then splice". Cloning is cheap (a menu is tens
|
|
144
|
+
* of items) and it keeps every operation a pure function of the previous tree — which is
|
|
145
|
+
* what makes undo-by-not-saving work, and what stops a move from mutating an item that a
|
|
146
|
+
* later step in the same handler is still reading. */
|
|
147
|
+
function cloneTree(items: readonly MenuItem[]): MenuItem[] {
|
|
148
|
+
return items.map((it) => ({ ...it, children: it.children ? cloneTree(it.children) : undefined }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function removeAt(items: readonly MenuItem[], path: readonly number[]): { tree: MenuItem[]; removed: MenuItem } {
|
|
152
|
+
const tree = cloneTree(items);
|
|
153
|
+
const { list, index } = siblingsAt(tree, path);
|
|
154
|
+
const [removed] = list.splice(index, 1);
|
|
155
|
+
return { tree, removed: removed! };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function MenuEditor({ api, name, collections, onBack, onDeleted, onError, canEdit }: {
|
|
159
|
+
api: Api;
|
|
160
|
+
name: string;
|
|
161
|
+
collections: CollectionMeta[];
|
|
162
|
+
onBack: () => void;
|
|
163
|
+
onDeleted: () => void;
|
|
164
|
+
onError: (s: string) => void;
|
|
165
|
+
canEdit: boolean;
|
|
166
|
+
}) {
|
|
167
|
+
const [menu, setMenu] = useState<Menu | null>(null);
|
|
168
|
+
const [items, setItems] = useState<MenuItem[]>([]);
|
|
169
|
+
const [label, setLabel] = useState("");
|
|
170
|
+
// The whole tree is edited locally and written only by "Save menu", so leaving the screen
|
|
171
|
+
// discards it. Nothing prompted before this — `PageEditor` was the only screen that ever
|
|
172
|
+
// registered a guard.
|
|
173
|
+
const [baseline, setBaseline] = useState("");
|
|
174
|
+
useUnsavedGuard(menu !== null && JSON.stringify({ label, items }) !== baseline);
|
|
175
|
+
// The version this screen loaded. Sent back on save, so a second editor's whole-tree
|
|
176
|
+
// overwrite is a 409 the person can act on rather than a silent replacement.
|
|
177
|
+
const [version, setVersion] = useState<number | undefined>(undefined);
|
|
178
|
+
const [missing, setMissing] = useState(false);
|
|
179
|
+
const [busy, setBusy] = useState(false);
|
|
180
|
+
const [ok, setOk] = useState(false);
|
|
181
|
+
// Reference targets, fetched once: a menu item points at a page or a term by id, and a
|
|
182
|
+
// raw uuid field would make this unusable.
|
|
183
|
+
const [pages, setPages] = useState<Page[]>([]);
|
|
184
|
+
const [terms, setTerms] = useState<Array<{ id: string; label: string; taxonomy: string }>>([]);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
let live = true;
|
|
188
|
+
// `listMenus` (raw) rather than `getMenu` (resolved): the editor must show what is
|
|
189
|
+
// STORED — a reference to a page that is currently unpublished is dropped from the
|
|
190
|
+
// public read, and editing against that view would silently delete those items on save.
|
|
191
|
+
api.listMenus()
|
|
192
|
+
.then((all) => {
|
|
193
|
+
if (!live) return;
|
|
194
|
+
const m = all.find((x) => x.name === name);
|
|
195
|
+
if (!m) { setMissing(true); return; }
|
|
196
|
+
setMenu(m); setLabel(m.label); setItems(m.items ?? []);
|
|
197
|
+
setVersion(m.version);
|
|
198
|
+
setBaseline(JSON.stringify({ label: m.label, items: m.items ?? [] }));
|
|
199
|
+
})
|
|
200
|
+
.catch((e) => onError(errText(e)));
|
|
201
|
+
api.listPages({ limit: 200 }).then((r) => live && setPages(r)).catch(() => setPages([]));
|
|
202
|
+
api.listTaxonomies()
|
|
203
|
+
.then(async (taxa) => {
|
|
204
|
+
// One request per vocabulary, in PARALLEL — the picker cannot render until the last
|
|
205
|
+
// of them lands either way, so serializing them only added latency.
|
|
206
|
+
const trees = await Promise.all(taxa.map(async (t) => [t, await api.getTermTree(t.slug).catch(() => [] as Term[])] as const));
|
|
207
|
+
const all: Array<{ id: string; label: string; taxonomy: string }> = [];
|
|
208
|
+
for (const [t, tree] of trees) {
|
|
209
|
+
const walk = (list: Term[], prefix: string) => {
|
|
210
|
+
for (const term of list) {
|
|
211
|
+
all.push({ id: term.id, label: `${prefix}${term.label}`, taxonomy: t.label });
|
|
212
|
+
if (term.children) walk(term.children, `${prefix}${term.label} / `);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
walk(tree, "");
|
|
216
|
+
}
|
|
217
|
+
if (live) setTerms(all);
|
|
218
|
+
})
|
|
219
|
+
.catch(() => setTerms([]));
|
|
220
|
+
return () => { live = false; };
|
|
221
|
+
}, [api, name, onError]);
|
|
222
|
+
|
|
223
|
+
const save = async () => {
|
|
224
|
+
if (!menu) return;
|
|
225
|
+
setBusy(true);
|
|
226
|
+
try {
|
|
227
|
+
const saved = await api.updateMenu(menu.id, { label, items, expectedVersion: version });
|
|
228
|
+
setVersion(saved.version);
|
|
229
|
+
setBaseline(JSON.stringify({ label, items }));
|
|
230
|
+
setOk(true); setTimeout(() => setOk(false), 1200);
|
|
231
|
+
} catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const del = async () => {
|
|
235
|
+
if (!menu || !confirm(`Delete the menu “${menu.label}”? Any layout reading it will render nothing.`)) return;
|
|
236
|
+
setBusy(true);
|
|
237
|
+
try { await api.deleteMenu(menu.id); onDeleted(); } catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const flat = flatten(items);
|
|
241
|
+
|
|
242
|
+
const patch = (path: number[], p: Partial<MenuItem>) => {
|
|
243
|
+
const tree = cloneTree(items);
|
|
244
|
+
const { list, index } = siblingsAt(tree, path);
|
|
245
|
+
list[index] = { ...list[index]!, ...p };
|
|
246
|
+
setItems(tree);
|
|
247
|
+
};
|
|
248
|
+
const remove = (path: number[]) => setItems(removeAt(items, path).tree);
|
|
249
|
+
const move = (path: number[], d: number) => {
|
|
250
|
+
const tree = cloneTree(items);
|
|
251
|
+
const { list, index } = siblingsAt(tree, path);
|
|
252
|
+
const to = index + d;
|
|
253
|
+
if (to < 0 || to >= list.length) return;
|
|
254
|
+
const [moved] = list.splice(index, 1);
|
|
255
|
+
list.splice(to, 0, moved!);
|
|
256
|
+
setItems(tree);
|
|
257
|
+
};
|
|
258
|
+
/** Indent: become a child of the PREVIOUS SIBLING, which is the only unambiguous parent
|
|
259
|
+
* an indent can mean. Refused at the top of a list (nothing to nest under) and at the
|
|
260
|
+
* depth cap the server enforces. */
|
|
261
|
+
const indent = (path: number[], depth: number) => {
|
|
262
|
+
const { index } = siblingsAt(items, path);
|
|
263
|
+
if (index === 0 || depth + 1 >= MAX_MENU_DEPTH) return;
|
|
264
|
+
const { tree, removed } = removeAt(items, path);
|
|
265
|
+
const { list } = siblingsAt(tree, path);
|
|
266
|
+
const prev = list[index - 1]!;
|
|
267
|
+
prev.children = [...(prev.children ?? []), removed];
|
|
268
|
+
setItems(tree);
|
|
269
|
+
};
|
|
270
|
+
/** Outdent: become the next sibling of the parent. */
|
|
271
|
+
const outdent = (path: number[]) => {
|
|
272
|
+
if (path.length < 2) return;
|
|
273
|
+
const { tree, removed } = removeAt(items, path);
|
|
274
|
+
const parentPath = path.slice(0, -1);
|
|
275
|
+
const { list, index } = siblingsAt(tree, parentPath);
|
|
276
|
+
list.splice(index + 1, 0, removed);
|
|
277
|
+
setItems(tree);
|
|
278
|
+
};
|
|
279
|
+
const add = () => setItems([...items, { id: crypto.randomUUID(), label: "New item", kind: "custom", url: "/" }]);
|
|
280
|
+
|
|
281
|
+
if (missing) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Unknown menu: {name}</p></div>;
|
|
282
|
+
if (!menu) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Loading…</p></div>;
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<div className={WRAP}>
|
|
286
|
+
<div className="mb-4 mt-2 flex items-center gap-3">
|
|
287
|
+
<Button variant="ghost" size="sm" onPress={onBack}>← Menus</Button>
|
|
288
|
+
<h1 className="text-[22px] font-normal text-fg">{menu.label}</h1>
|
|
289
|
+
<span className="text-fg-subtle">{menu.name}</span>
|
|
290
|
+
</div>
|
|
291
|
+
<div className="flex max-w-[860px] flex-col gap-4">
|
|
292
|
+
{ok ? <Saved /> : null}
|
|
293
|
+
<Input label="Label" value={label} onChange={setLabel} />
|
|
294
|
+
<div className="flex flex-col gap-2">
|
|
295
|
+
{flat.length === 0 ? <p className="text-sm text-fg-subtle">No items yet.</p> : null}
|
|
296
|
+
{flat.map(({ item, path, depth }) => (
|
|
297
|
+
<div key={item.id} style={{ marginLeft: depth * 24 }}>
|
|
298
|
+
<MenuItemRow
|
|
299
|
+
item={item}
|
|
300
|
+
depth={depth}
|
|
301
|
+
pages={pages}
|
|
302
|
+
terms={terms}
|
|
303
|
+
collections={collections}
|
|
304
|
+
onPatch={(p) => patch(path, p)}
|
|
305
|
+
onMove={(d) => move(path, d)}
|
|
306
|
+
onIndent={() => indent(path, depth)}
|
|
307
|
+
onOutdent={() => outdent(path)}
|
|
308
|
+
onRemove={() => remove(path)}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
{canEdit ? (
|
|
314
|
+
<div className="flex items-center gap-2">
|
|
315
|
+
<Button variant="secondary" size="sm" onPress={add}>+ Add item</Button>
|
|
316
|
+
<Button onPress={save} isDisabled={busy}>{busy ? "Saving…" : "Save menu"}</Button>
|
|
317
|
+
<Button variant="ghost" className="text-danger" onPress={del} isDisabled={busy}>Delete menu</Button>
|
|
318
|
+
</div>
|
|
319
|
+
) : null}
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const MENU_KINDS: { value: MenuItemKind; label: string }[] = [
|
|
326
|
+
{ value: "custom", label: "A URL" },
|
|
327
|
+
{ value: "page", label: "A page" },
|
|
328
|
+
{ value: "term", label: "A term" },
|
|
329
|
+
{ value: "collection", label: "A collection" },
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
function MenuItemRow({ item, depth, pages, terms, collections, onPatch, onMove, onIndent, onOutdent, onRemove }: {
|
|
333
|
+
item: MenuItem;
|
|
334
|
+
depth: number;
|
|
335
|
+
pages: Page[];
|
|
336
|
+
terms: Array<{ id: string; label: string; taxonomy: string }>;
|
|
337
|
+
collections: CollectionMeta[];
|
|
338
|
+
onPatch: (p: Partial<MenuItem>) => void;
|
|
339
|
+
onMove: (d: number) => void;
|
|
340
|
+
onIndent: () => void;
|
|
341
|
+
onOutdent: () => void;
|
|
342
|
+
onRemove: () => void;
|
|
343
|
+
}) {
|
|
344
|
+
const kind = item.kind ?? "custom";
|
|
345
|
+
return (
|
|
346
|
+
<div className="rounded-lg border border-border bg-surface-muted p-3.5">
|
|
347
|
+
<div className="grid grid-cols-[1fr_auto] gap-3">
|
|
348
|
+
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
|
349
|
+
<label className="flex flex-col gap-1.5">
|
|
350
|
+
<span className="text-caption text-fg-subtle">Label</span>
|
|
351
|
+
<input className={CONTROL} value={item.label} onChange={(e) => onPatch({ label: e.target.value })} />
|
|
352
|
+
</label>
|
|
353
|
+
<label className="flex flex-col gap-1.5">
|
|
354
|
+
<span className="text-caption text-fg-subtle">Points at</span>
|
|
355
|
+
<select
|
|
356
|
+
className={CONTROL}
|
|
357
|
+
value={kind}
|
|
358
|
+
// Changing the kind clears the OTHER kind's target. Keeping both would post a
|
|
359
|
+
// `ref` alongside a `url` and store whichever the server happens to read.
|
|
360
|
+
onChange={(e) => onPatch({ kind: e.target.value as MenuItemKind, ref: null, url: e.target.value === "custom" ? "/" : undefined })}
|
|
361
|
+
>
|
|
362
|
+
{MENU_KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
|
|
363
|
+
</select>
|
|
364
|
+
</label>
|
|
365
|
+
</div>
|
|
366
|
+
<div className="flex shrink-0 items-start gap-0.5 pt-5">
|
|
367
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-fg" title="Move up" onClick={() => onMove(-1)}>↑</button>
|
|
368
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-fg" title="Move down" onClick={() => onMove(1)}>↓</button>
|
|
369
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-fg disabled:opacity-30" title="Nest under the item above" disabled={depth + 1 >= MAX_MENU_DEPTH} onClick={onIndent}>→</button>
|
|
370
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-fg disabled:opacity-30" title="Move out a level" disabled={depth === 0} onClick={onOutdent}>←</button>
|
|
371
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-danger" title="Remove" onClick={onRemove}>✕</button>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<div className="mt-3 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
|
376
|
+
{kind === "custom" ? (
|
|
377
|
+
<label className="flex flex-col gap-1.5">
|
|
378
|
+
<span className="text-caption text-fg-subtle">URL</span>
|
|
379
|
+
<input className={CONTROL} value={item.url ?? ""} placeholder="/about or https://…" onChange={(e) => onPatch({ url: e.target.value })} />
|
|
380
|
+
</label>
|
|
381
|
+
) : (
|
|
382
|
+
<label className="flex flex-col gap-1.5">
|
|
383
|
+
<span className="text-caption text-fg-subtle">Target</span>
|
|
384
|
+
<select className={CONTROL} value={item.ref ?? ""} onChange={(e) => onPatch({ ref: e.target.value || null })}>
|
|
385
|
+
<option value="">— pick one —</option>
|
|
386
|
+
{kind === "page" ? pages.map((p) => <option key={p.id} value={p.id}>{p.title} ({p.slug})</option>) : null}
|
|
387
|
+
{kind === "term" ? terms.map((t) => <option key={t.id} value={t.id}>{t.taxonomy}: {t.label}</option>) : null}
|
|
388
|
+
{kind === "collection" ? collections.map((c) => <option key={c.slug} value={c.slug}>{c.pluralLabel}</option>) : null}
|
|
389
|
+
</select>
|
|
390
|
+
</label>
|
|
391
|
+
)}
|
|
392
|
+
<label className="flex flex-col gap-1.5">
|
|
393
|
+
<span className="text-caption text-fg-subtle">Opens in</span>
|
|
394
|
+
<select className={CONTROL} value={item.target ?? ""} onChange={(e) => onPatch({ target: e.target.value || undefined })}>
|
|
395
|
+
<option value="">this tab</option>
|
|
396
|
+
<option value="_blank">a new tab</option>
|
|
397
|
+
</select>
|
|
398
|
+
</label>
|
|
399
|
+
</div>
|
|
400
|
+
{kind !== "custom" && !item.ref ? (
|
|
401
|
+
<p className="mt-2 text-caption text-danger">Pick a target, or this item cannot be saved.</p>
|
|
402
|
+
) : null}
|
|
403
|
+
{kind !== "custom" ? (
|
|
404
|
+
<p className="mt-2 text-caption text-fg-subtle">
|
|
405
|
+
The URL is worked out when the menu is read, so it follows the target. An item whose target is unpublished or gone is left out of the public menu rather than rendered as a dead link.
|
|
406
|
+
</p>
|
|
407
|
+
) : null}
|
|
408
|
+
</div>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --- redirects ------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
export function RedirectsView({ api, onError, canEdit }: { api: Api; onError: (s: string) => void; canEdit: boolean }) {
|
|
415
|
+
const [rows, setRows] = useState<Redirect[] | null>(null);
|
|
416
|
+
const [from, setFrom] = useState("");
|
|
417
|
+
const [to, setTo] = useState("");
|
|
418
|
+
const [status, setStatus] = useState(301);
|
|
419
|
+
const [busy, setBusy] = useState(false);
|
|
420
|
+
|
|
421
|
+
const refresh = useCallback(() => {
|
|
422
|
+
api.listRedirects().then(setRows).catch((e) => { setRows([]); onError(errText(e)); });
|
|
423
|
+
}, [api, onError]);
|
|
424
|
+
useEffect(refresh, [refresh]);
|
|
425
|
+
|
|
426
|
+
const create = async () => {
|
|
427
|
+
setBusy(true);
|
|
428
|
+
try {
|
|
429
|
+
await api.createRedirect({ fromPath: from, toPath: to, status });
|
|
430
|
+
setFrom(""); setTo("");
|
|
431
|
+
refresh();
|
|
432
|
+
} catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
433
|
+
};
|
|
434
|
+
const patch = async (r: Redirect, p: Parameters<Api["updateRedirect"]>[1]) => {
|
|
435
|
+
try { await api.updateRedirect(r.id, p); refresh(); } catch (e) { onError(errText(e)); }
|
|
436
|
+
};
|
|
437
|
+
const del = async (r: Redirect) => {
|
|
438
|
+
if (!confirm(`Delete the redirect from ${r.fromPath}?`)) return;
|
|
439
|
+
try { await api.deleteRedirect(r.id); refresh(); } catch (e) { onError(errText(e)); }
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<div className={WRAP}>
|
|
444
|
+
<Head lead="Old URLs, kept alive" em={rows === null ? "Redirects" : rows.length === 1 ? "1 redirect" : `${rows.length} redirects`} />
|
|
445
|
+
<p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
|
|
446
|
+
Changing a page's slug changes a live URL and breaks every link to it. A redirect is how the old one keeps working.
|
|
447
|
+
Disabling one keeps the record of what the old URL was, which deleting it does not.
|
|
448
|
+
</p>
|
|
449
|
+
<div className="flex flex-col gap-2">
|
|
450
|
+
{rows === null ? <p className="text-fg-subtle">Loading…</p> : null}
|
|
451
|
+
{rows?.length === 0 ? <p className="text-fg-subtle">No redirects yet.</p> : null}
|
|
452
|
+
{(rows ?? []).map((r) => (
|
|
453
|
+
<div key={r.id} className={`${ROW} ${r.enabled ? "" : "opacity-60"}`}>
|
|
454
|
+
<span className="min-w-0 flex-1 truncate font-medium">{r.fromPath}</span>
|
|
455
|
+
<span className="shrink-0 text-fg-subtle">→</span>
|
|
456
|
+
<span className="min-w-0 flex-1 truncate text-fg-muted">{r.toPath}</span>
|
|
457
|
+
<span className="shrink-0 text-caption text-fg-subtle">{r.status}</span>
|
|
458
|
+
{canEdit ? (
|
|
459
|
+
<>
|
|
460
|
+
<Button variant="ghost" size="sm" onPress={() => patch(r, { enabled: !r.enabled })}>{r.enabled ? "disable" : "enable"}</Button>
|
|
461
|
+
<Button variant="ghost" size="sm" className="text-danger" onPress={() => del(r)}>delete</Button>
|
|
462
|
+
</>
|
|
463
|
+
) : null}
|
|
464
|
+
</div>
|
|
465
|
+
))}
|
|
466
|
+
</div>
|
|
467
|
+
{canEdit ? (
|
|
468
|
+
<div className="mt-6 max-w-[860px] rounded-lg border border-border bg-surface-muted p-4">
|
|
469
|
+
<Heading level="2" className="mb-3 font-normal">New redirect</Heading>
|
|
470
|
+
<div className="grid grid-cols-[1fr_1fr_auto] gap-3 max-[720px]:grid-cols-1">
|
|
471
|
+
<label className="flex flex-col gap-1.5">
|
|
472
|
+
<span className="text-caption text-fg-subtle">From (a path on this site)</span>
|
|
473
|
+
<input className={CONTROL} value={from} placeholder="/old-page" onChange={(e) => setFrom(e.target.value)} />
|
|
474
|
+
</label>
|
|
475
|
+
<label className="flex flex-col gap-1.5">
|
|
476
|
+
<span className="text-caption text-fg-subtle">To (a path, or a full URL)</span>
|
|
477
|
+
<input className={CONTROL} value={to} placeholder="/new-page" onChange={(e) => setTo(e.target.value)} />
|
|
478
|
+
</label>
|
|
479
|
+
<label className="flex flex-col gap-1.5">
|
|
480
|
+
<span className="text-caption text-fg-subtle">Status</span>
|
|
481
|
+
<select className={CONTROL} value={status} onChange={(e) => setStatus(Number(e.target.value))}>
|
|
482
|
+
{REDIRECT_STATUSES.map((s) => <option key={s} value={s}>{s}{s === 301 || s === 308 ? " permanent" : " temporary"}</option>)}
|
|
483
|
+
</select>
|
|
484
|
+
</label>
|
|
485
|
+
</div>
|
|
486
|
+
<Button className="mt-3" onPress={create} isDisabled={busy || !from.trim() || !to.trim()}>Add redirect</Button>
|
|
487
|
+
</div>
|
|
488
|
+
) : null}
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --- taxonomies -----------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
export function TaxonomiesView({ api, onOpen, onError, canEdit }: { api: Api; onOpen: (slug: string) => void; onError: (s: string) => void; canEdit: boolean }) {
|
|
496
|
+
const [taxa, setTaxa] = useState<Taxonomy[] | null>(null);
|
|
497
|
+
const [label, setLabel] = useState("");
|
|
498
|
+
const [slug, setSlug] = useState("");
|
|
499
|
+
const [slugTouched, setSlugTouched] = useState(false);
|
|
500
|
+
const [hierarchical, setHierarchical] = useState(false);
|
|
501
|
+
const [busy, setBusy] = useState(false);
|
|
502
|
+
|
|
503
|
+
const refresh = useCallback(() => {
|
|
504
|
+
api.listTaxonomies().then(setTaxa).catch((e) => { setTaxa([]); onError(errText(e)); });
|
|
505
|
+
}, [api, onError]);
|
|
506
|
+
useEffect(refresh, [refresh]);
|
|
507
|
+
|
|
508
|
+
const create = async () => {
|
|
509
|
+
setBusy(true);
|
|
510
|
+
try {
|
|
511
|
+
const created = await api.createTaxonomy({ slug, label, hierarchical });
|
|
512
|
+
setLabel(""); setSlug(""); setSlugTouched(false); setHierarchical(false);
|
|
513
|
+
onOpen(created.slug);
|
|
514
|
+
} catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
<div className={WRAP}>
|
|
519
|
+
<Head lead="How this site" em="is classified" />
|
|
520
|
+
<p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
|
|
521
|
+
A vocabulary is a way of grouping pages — categories, tags, regions. There are no built-in ones:
|
|
522
|
+
a deployment declares what it sorts by, the same way it declares its content types.
|
|
523
|
+
</p>
|
|
524
|
+
<div className="flex flex-col gap-2">
|
|
525
|
+
{taxa === null ? <p className="text-fg-subtle">Loading…</p> : null}
|
|
526
|
+
{taxa?.length === 0 ? <p className="text-fg-subtle">No vocabularies yet.</p> : null}
|
|
527
|
+
{(taxa ?? []).map((t) => (
|
|
528
|
+
<div key={t.id} className={`${ROW} cursor-pointer hover:bg-surface-muted`} onClick={() => onOpen(t.slug)}>
|
|
529
|
+
<span className="min-w-0 flex-1 truncate font-medium">{t.label}</span>
|
|
530
|
+
<span className="shrink-0 truncate text-fg-subtle">{t.slug}</span>
|
|
531
|
+
<span className="shrink-0 text-caption text-fg-subtle">{t.hierarchical ? "nested" : "flat"}</span>
|
|
532
|
+
</div>
|
|
533
|
+
))}
|
|
534
|
+
</div>
|
|
535
|
+
{canEdit ? (
|
|
536
|
+
<div className="mt-6 max-w-[720px] rounded-lg border border-border bg-surface-muted p-4">
|
|
537
|
+
<Heading level="2" className="mb-3 font-normal">New vocabulary</Heading>
|
|
538
|
+
<KeyFields
|
|
539
|
+
label={label}
|
|
540
|
+
keyValue={slug}
|
|
541
|
+
onLabel={(v) => { setLabel(v); if (!slugTouched) setSlug(slugify(v)); }}
|
|
542
|
+
onKey={(v) => { setSlugTouched(true); setSlug(v); }}
|
|
543
|
+
keyHint="A URL segment — terms live under it. Not renameable afterwards."
|
|
544
|
+
/>
|
|
545
|
+
<label className="mt-3 flex items-center gap-2">
|
|
546
|
+
<input type="checkbox" checked={hierarchical} onChange={(e) => setHierarchical(e.target.checked)} />
|
|
547
|
+
<span className="text-sm text-fg">Terms can nest (categories rather than tags)</span>
|
|
548
|
+
</label>
|
|
549
|
+
<Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !slug.trim()}>Create vocabulary</Button>
|
|
550
|
+
</div>
|
|
551
|
+
) : null}
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function TaxonomyEditor({ api, slug, onBack, onDeleted, onError, canEdit }: {
|
|
557
|
+
api: Api;
|
|
558
|
+
slug: string;
|
|
559
|
+
onBack: () => void;
|
|
560
|
+
onDeleted: () => void;
|
|
561
|
+
onError: (s: string) => void;
|
|
562
|
+
canEdit: boolean;
|
|
563
|
+
}) {
|
|
564
|
+
const [tax, setTax] = useState<Taxonomy | null>(null);
|
|
565
|
+
const [tree, setTree] = useState<Term[] | null>(null);
|
|
566
|
+
const [missing, setMissing] = useState(false);
|
|
567
|
+
const [termLabel, setTermLabel] = useState("");
|
|
568
|
+
const [termSlug, setTermSlug] = useState("");
|
|
569
|
+
const [termSlugTouched, setTermSlugTouched] = useState(false);
|
|
570
|
+
const [parentId, setParentId] = useState("");
|
|
571
|
+
const [busy, setBusy] = useState(false);
|
|
572
|
+
|
|
573
|
+
const refreshTerms = useCallback(() => {
|
|
574
|
+
api.getTermTree(slug).then(setTree).catch((e) => { setTree([]); onError(errText(e)); });
|
|
575
|
+
}, [api, slug, onError]);
|
|
576
|
+
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
let live = true;
|
|
579
|
+
api.listTaxonomies()
|
|
580
|
+
.then((all) => {
|
|
581
|
+
if (!live) return;
|
|
582
|
+
const t = all.find((x) => x.slug === slug);
|
|
583
|
+
if (!t) { setMissing(true); return; }
|
|
584
|
+
setTax(t);
|
|
585
|
+
})
|
|
586
|
+
.catch((e) => onError(errText(e)));
|
|
587
|
+
return () => { live = false; };
|
|
588
|
+
}, [api, slug, onError]);
|
|
589
|
+
useEffect(refreshTerms, [refreshTerms]);
|
|
590
|
+
|
|
591
|
+
const flat = tree ? flattenTerms(tree) : [];
|
|
592
|
+
|
|
593
|
+
const addTerm = async () => {
|
|
594
|
+
setBusy(true);
|
|
595
|
+
try {
|
|
596
|
+
await api.createTerm({ taxonomy: slug, slug: termSlug, label: termLabel, parentId: parentId || null });
|
|
597
|
+
setTermLabel(""); setTermSlug(""); setTermSlugTouched(false); setParentId("");
|
|
598
|
+
refreshTerms();
|
|
599
|
+
} catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
600
|
+
};
|
|
601
|
+
const delTerm = async (t: Term) => {
|
|
602
|
+
if (!confirm(`Delete “${t.label}”? Pages tagged with it lose the tag; any terms under it move to the top level.`)) return;
|
|
603
|
+
try { await api.deleteTerm(t.id); refreshTerms(); } catch (e) { onError(errText(e)); }
|
|
604
|
+
};
|
|
605
|
+
const renameTerm = async (t: Term, label: string) => {
|
|
606
|
+
if (label === t.label) return;
|
|
607
|
+
try { await api.updateTerm(t.id, { label }); refreshTerms(); } catch (e) { onError(errText(e)); }
|
|
608
|
+
};
|
|
609
|
+
const delTaxonomy = async () => {
|
|
610
|
+
if (!tax || !confirm(`Delete the vocabulary “${tax.label}”? Every term in it goes too, along with every page's assignments.`)) return;
|
|
611
|
+
try { await api.deleteTaxonomy(tax.id); onDeleted(); } catch (e) { onError(errText(e)); }
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
if (missing) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Unknown vocabulary: {slug}</p></div>;
|
|
615
|
+
if (!tax) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Loading…</p></div>;
|
|
616
|
+
|
|
617
|
+
return (
|
|
618
|
+
<div className={WRAP}>
|
|
619
|
+
<div className="mb-4 mt-2 flex items-center gap-3">
|
|
620
|
+
<Button variant="ghost" size="sm" onPress={onBack}>← Taxonomies</Button>
|
|
621
|
+
<h1 className="text-[22px] font-normal text-fg">{tax.label}</h1>
|
|
622
|
+
<span className="text-fg-subtle">{tax.slug}</span>
|
|
623
|
+
</div>
|
|
624
|
+
<div className="flex max-w-[860px] flex-col gap-4">
|
|
625
|
+
<div className="flex flex-col gap-2">
|
|
626
|
+
{tree === null ? <p className="text-fg-subtle">Loading…</p> : null}
|
|
627
|
+
{tree?.length === 0 ? <p className="text-fg-subtle">No terms yet.</p> : null}
|
|
628
|
+
{flat.map(({ term, depth }) => (
|
|
629
|
+
<div key={term.id} className={ROW} style={{ marginLeft: depth * 24 }}>
|
|
630
|
+
<input
|
|
631
|
+
className={`${CONTROL} flex-1`}
|
|
632
|
+
defaultValue={term.label}
|
|
633
|
+
aria-label={`Label for ${term.label}`}
|
|
634
|
+
disabled={!canEdit}
|
|
635
|
+
// Committed on blur, not per keystroke: each save is a round trip, and a
|
|
636
|
+
// rename mid-word would land a term called "Ne".
|
|
637
|
+
onBlur={(e) => renameTerm(term, e.target.value.trim())}
|
|
638
|
+
/>
|
|
639
|
+
<span className="shrink-0 truncate text-fg-subtle">{term.slug}</span>
|
|
640
|
+
{canEdit ? <Button variant="ghost" size="sm" className="text-danger" onPress={() => delTerm(term)}>delete</Button> : null}
|
|
641
|
+
</div>
|
|
642
|
+
))}
|
|
643
|
+
</div>
|
|
644
|
+
|
|
645
|
+
{canEdit ? (
|
|
646
|
+
<div className="rounded-lg border border-border bg-surface-muted p-4">
|
|
647
|
+
<Heading level="2" className="mb-3 font-normal">New term</Heading>
|
|
648
|
+
<KeyFields
|
|
649
|
+
label={termLabel}
|
|
650
|
+
keyValue={termSlug}
|
|
651
|
+
onLabel={(v) => { setTermLabel(v); if (!termSlugTouched) setTermSlug(slugify(v)); }}
|
|
652
|
+
onKey={(v) => { setTermSlugTouched(true); setTermSlug(v); }}
|
|
653
|
+
keyHint="The URL segment for this term."
|
|
654
|
+
/>
|
|
655
|
+
{tax.hierarchical ? (
|
|
656
|
+
<label className="mt-3 flex flex-col gap-1.5">
|
|
657
|
+
<span className="text-caption text-fg-subtle">Nested under</span>
|
|
658
|
+
<select className={CONTROL} value={parentId} onChange={(e) => setParentId(e.target.value)}>
|
|
659
|
+
<option value="">— top level —</option>
|
|
660
|
+
{flat.map(({ term, depth }) => <option key={term.id} value={term.id}>{"— ".repeat(depth)}{term.label}</option>)}
|
|
661
|
+
</select>
|
|
662
|
+
</label>
|
|
663
|
+
) : null}
|
|
664
|
+
<Button className="mt-3" onPress={addTerm} isDisabled={busy || !termLabel.trim() || !termSlug.trim()}>Add term</Button>
|
|
665
|
+
</div>
|
|
666
|
+
) : null}
|
|
667
|
+
|
|
668
|
+
{canEdit ? <Button variant="ghost" className="self-start text-danger" onPress={delTaxonomy}>Delete this vocabulary</Button> : null}
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
interface FlatTerm { term: Term; depth: number }
|
|
675
|
+
|
|
676
|
+
export function flattenTerms(tree: readonly Term[], depth = 0): FlatTerm[] {
|
|
677
|
+
const out: FlatTerm[] = [];
|
|
678
|
+
for (const term of tree) {
|
|
679
|
+
out.push({ term, depth });
|
|
680
|
+
if (term.children) out.push(...flattenTerms(term.children, depth + 1));
|
|
681
|
+
}
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// --- widget areas ---------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
export function WidgetAreasView({ api, onOpen, onError, canEdit }: { api: Api; onOpen: (name: string) => void; onError: (s: string) => void; canEdit: boolean }) {
|
|
688
|
+
const [areas, setAreas] = useState<WidgetArea[] | null>(null);
|
|
689
|
+
const [label, setLabel] = useState("");
|
|
690
|
+
const [name, setName] = useState("");
|
|
691
|
+
const [nameTouched, setNameTouched] = useState(false);
|
|
692
|
+
const [busy, setBusy] = useState(false);
|
|
693
|
+
|
|
694
|
+
const refresh = useCallback(() => {
|
|
695
|
+
api.listWidgetAreas().then(setAreas).catch((e) => { setAreas([]); onError(errText(e)); });
|
|
696
|
+
}, [api, onError]);
|
|
697
|
+
useEffect(refresh, [refresh]);
|
|
698
|
+
|
|
699
|
+
const create = async () => {
|
|
700
|
+
setBusy(true);
|
|
701
|
+
try {
|
|
702
|
+
const created = await api.createWidgetArea(name, label);
|
|
703
|
+
setLabel(""); setName(""); setNameTouched(false);
|
|
704
|
+
onOpen(created.name);
|
|
705
|
+
} catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
return (
|
|
709
|
+
<div className={WRAP}>
|
|
710
|
+
<Head lead="Parts of the layout" em="you can fill in" />
|
|
711
|
+
<p className="mb-4 max-w-[62ch] text-sm text-fg-muted">
|
|
712
|
+
A widget area is a named slot in your layout — a sidebar, a footer column — that an editor fills without touching code.
|
|
713
|
+
Your layout reads one by name: <code>getWidgetArea("sidebar")</code>.
|
|
714
|
+
</p>
|
|
715
|
+
<div className="flex flex-col gap-2">
|
|
716
|
+
{areas === null ? <p className="text-fg-subtle">Loading…</p> : null}
|
|
717
|
+
{areas?.length === 0 ? <p className="text-fg-subtle">No widget areas yet.</p> : null}
|
|
718
|
+
{(areas ?? []).map((a) => (
|
|
719
|
+
<div key={a.id} className={`${ROW} cursor-pointer hover:bg-surface-muted`} onClick={() => onOpen(a.name)}>
|
|
720
|
+
<span className="min-w-0 flex-1 truncate font-medium">{a.label}</span>
|
|
721
|
+
<span className="shrink-0 truncate text-fg-subtle">{a.name}</span>
|
|
722
|
+
<span className="shrink-0 text-caption text-fg-subtle">{(a.widgets ?? []).length} widget(s)</span>
|
|
723
|
+
</div>
|
|
724
|
+
))}
|
|
725
|
+
</div>
|
|
726
|
+
{canEdit ? (
|
|
727
|
+
<div className="mt-6 max-w-[720px] rounded-lg border border-border bg-surface-muted p-4">
|
|
728
|
+
<Heading level="2" className="mb-3 font-normal">New widget area</Heading>
|
|
729
|
+
<KeyFields
|
|
730
|
+
label={label}
|
|
731
|
+
keyValue={name}
|
|
732
|
+
onLabel={(v) => { setLabel(v); if (!nameTouched) setName(slugify(v)); }}
|
|
733
|
+
onKey={(v) => { setNameTouched(true); setName(v); }}
|
|
734
|
+
keyHint="What your layout asks for. Not renameable afterwards."
|
|
735
|
+
/>
|
|
736
|
+
<Button className="mt-3" onPress={create} isDisabled={busy || !label.trim() || !name.trim()}>Create widget area</Button>
|
|
737
|
+
</div>
|
|
738
|
+
) : null}
|
|
739
|
+
</div>
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function WidgetAreaEditor({ api, name, onBack, onDeleted, onError, canEdit }: {
|
|
744
|
+
api: Api;
|
|
745
|
+
name: string;
|
|
746
|
+
onBack: () => void;
|
|
747
|
+
onDeleted: () => void;
|
|
748
|
+
onError: (s: string) => void;
|
|
749
|
+
canEdit: boolean;
|
|
750
|
+
}) {
|
|
751
|
+
const [area, setArea] = useState<WidgetArea | null>(null);
|
|
752
|
+
const [label, setLabel] = useState("");
|
|
753
|
+
const [widgets, setWidgets] = useState<Widget[]>([]);
|
|
754
|
+
const [menus, setMenus] = useState<Menu[]>([]);
|
|
755
|
+
const [baseline, setBaseline] = useState("");
|
|
756
|
+
useUnsavedGuard(area !== null && JSON.stringify({ label, widgets }) !== baseline);
|
|
757
|
+
const [version, setVersion] = useState<number | undefined>(undefined);
|
|
758
|
+
const [missing, setMissing] = useState(false);
|
|
759
|
+
const [busy, setBusy] = useState(false);
|
|
760
|
+
const [ok, setOk] = useState(false);
|
|
761
|
+
|
|
762
|
+
useEffect(() => {
|
|
763
|
+
let live = true;
|
|
764
|
+
api.listWidgetAreas()
|
|
765
|
+
.then((all) => {
|
|
766
|
+
if (!live) return;
|
|
767
|
+
const a = all.find((x) => x.name === name);
|
|
768
|
+
if (!a) { setMissing(true); return; }
|
|
769
|
+
setArea(a); setLabel(a.label); setWidgets(a.widgets ?? []);
|
|
770
|
+
setVersion(a.version);
|
|
771
|
+
setBaseline(JSON.stringify({ label: a.label, widgets: a.widgets ?? [] }));
|
|
772
|
+
})
|
|
773
|
+
.catch((e) => onError(errText(e)));
|
|
774
|
+
api.listMenus().then((r) => live && setMenus(r)).catch(() => setMenus([]));
|
|
775
|
+
return () => { live = false; };
|
|
776
|
+
}, [api, name, onError]);
|
|
777
|
+
|
|
778
|
+
const save = async () => {
|
|
779
|
+
if (!area) return;
|
|
780
|
+
setBusy(true);
|
|
781
|
+
try {
|
|
782
|
+
const saved = await api.updateWidgetArea(area.id, { label, widgets, expectedVersion: version });
|
|
783
|
+
setVersion(saved.version);
|
|
784
|
+
setBaseline(JSON.stringify({ label, widgets }));
|
|
785
|
+
setOk(true); setTimeout(() => setOk(false), 1200);
|
|
786
|
+
} catch (e) { onError(errText(e)); } finally { setBusy(false); }
|
|
787
|
+
};
|
|
788
|
+
const del = async () => {
|
|
789
|
+
if (!area || !confirm(`Delete the widget area “${area.label}”?`)) return;
|
|
790
|
+
try { await api.deleteWidgetArea(area.id); onDeleted(); } catch (e) { onError(errText(e)); }
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const set = (i: number, w: Widget) => setWidgets(widgets.map((x, j) => (j === i ? w : x)));
|
|
794
|
+
const move = (i: number, d: number) => {
|
|
795
|
+
const to = i + d;
|
|
796
|
+
if (to < 0 || to >= widgets.length) return;
|
|
797
|
+
const next = widgets.slice();
|
|
798
|
+
const [moved] = next.splice(i, 1);
|
|
799
|
+
next.splice(to, 0, moved!);
|
|
800
|
+
setWidgets(next);
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
if (missing) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Unknown widget area: {name}</p></div>;
|
|
804
|
+
if (!area) return <div className={WRAP}><p className="pt-8 text-fg-subtle">Loading…</p></div>;
|
|
805
|
+
|
|
806
|
+
return (
|
|
807
|
+
<div className={WRAP}>
|
|
808
|
+
<div className="mb-4 mt-2 flex items-center gap-3">
|
|
809
|
+
<Button variant="ghost" size="sm" onPress={onBack}>← Widgets</Button>
|
|
810
|
+
<h1 className="text-[22px] font-normal text-fg">{area.label}</h1>
|
|
811
|
+
<span className="text-fg-subtle">{area.name}</span>
|
|
812
|
+
</div>
|
|
813
|
+
<div className="flex max-w-[860px] flex-col gap-4">
|
|
814
|
+
{ok ? <Saved /> : null}
|
|
815
|
+
<Input label="Label" value={label} onChange={setLabel} />
|
|
816
|
+
<div className="flex flex-col gap-2">
|
|
817
|
+
{widgets.length === 0 ? <p className="text-sm text-fg-subtle">No widgets yet.</p> : null}
|
|
818
|
+
{widgets.map((w, i) => (
|
|
819
|
+
<WidgetRow
|
|
820
|
+
key={w.id}
|
|
821
|
+
widget={w}
|
|
822
|
+
menus={menus}
|
|
823
|
+
onChange={(next) => set(i, next)}
|
|
824
|
+
onMove={(d) => move(i, d)}
|
|
825
|
+
onRemove={() => setWidgets(widgets.filter((_, j) => j !== i))}
|
|
826
|
+
/>
|
|
827
|
+
))}
|
|
828
|
+
</div>
|
|
829
|
+
{canEdit ? (
|
|
830
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
831
|
+
<Button variant="secondary" size="sm" onPress={() => setWidgets([...widgets, { id: crypto.randomUUID(), type: "content", content: { type: "doc", content: [] } }])}>+ Text</Button>
|
|
832
|
+
<Button variant="secondary" size="sm" onPress={() => setWidgets([...widgets, { id: crypto.randomUUID(), type: "menu", menuName: menus[0]?.name ?? "" }])}>+ Menu</Button>
|
|
833
|
+
<Button variant="secondary" size="sm" onPress={() => setWidgets([...widgets, { id: crypto.randomUUID(), type: "component", componentId: "" }])}>+ Component</Button>
|
|
834
|
+
<Button onPress={save} isDisabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
|
835
|
+
<Button variant="ghost" className="text-danger" onPress={del} isDisabled={busy}>Delete area</Button>
|
|
836
|
+
</div>
|
|
837
|
+
) : null}
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function WidgetRow({ widget, menus, onChange, onMove, onRemove }: {
|
|
844
|
+
widget: Widget;
|
|
845
|
+
menus: Menu[];
|
|
846
|
+
onChange: (w: Widget) => void;
|
|
847
|
+
onMove: (d: number) => void;
|
|
848
|
+
onRemove: () => void;
|
|
849
|
+
}) {
|
|
850
|
+
const patch = (p: Partial<Widget>) => onChange({ ...widget, ...p });
|
|
851
|
+
return (
|
|
852
|
+
<div className="rounded-lg border border-border bg-surface-muted">
|
|
853
|
+
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
854
|
+
<span className="text-caption text-fg-subtle">{widget.type}</span>
|
|
855
|
+
<input
|
|
856
|
+
className="min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
|
|
857
|
+
value={widget.title ?? ""}
|
|
858
|
+
placeholder="Title (optional)"
|
|
859
|
+
aria-label="Widget title"
|
|
860
|
+
onChange={(e) => patch({ title: e.target.value || null })}
|
|
861
|
+
/>
|
|
862
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-fg" title="Move up" onClick={() => onMove(-1)}>↑</button>
|
|
863
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-fg" title="Move down" onClick={() => onMove(1)}>↓</button>
|
|
864
|
+
<button type="button" className="px-1.5 text-fg-subtle hover:text-danger" title="Remove" onClick={onRemove}>✕</button>
|
|
865
|
+
</div>
|
|
866
|
+
<div className="p-3.5">
|
|
867
|
+
{widget.type === "content" ? (
|
|
868
|
+
<RichText value={(widget.content as RichTextDoc | null) ?? null} onChange={(content) => patch({ content })} />
|
|
869
|
+
) : widget.type === "menu" ? (
|
|
870
|
+
<label className="flex flex-col gap-1.5">
|
|
871
|
+
<span className="text-caption text-fg-subtle">Menu</span>
|
|
872
|
+
<select className={CONTROL} value={widget.menuName ?? ""} onChange={(e) => patch({ menuName: e.target.value })}>
|
|
873
|
+
<option value="">— pick one —</option>
|
|
874
|
+
{menus.map((m) => <option key={m.name} value={m.name}>{m.label}</option>)}
|
|
875
|
+
</select>
|
|
876
|
+
</label>
|
|
877
|
+
) : (
|
|
878
|
+
<div className="flex flex-col gap-3">
|
|
879
|
+
<label className="flex flex-col gap-1.5">
|
|
880
|
+
<span className="text-caption text-fg-subtle">Component id — your front end maps this to one of its own components</span>
|
|
881
|
+
<input className={CONTROL} value={widget.componentId ?? ""} onChange={(e) => patch({ componentId: e.target.value.trim() })} />
|
|
882
|
+
</label>
|
|
883
|
+
<JsonProps value={widget.componentProps} onChange={(componentProps) => patch({ componentProps })} />
|
|
884
|
+
</div>
|
|
885
|
+
)}
|
|
886
|
+
</div>
|
|
887
|
+
</div>
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Props for a `component` widget, edited as JSON.
|
|
892
|
+
*
|
|
893
|
+
* A structured editor is impossible here: the CMS does not know the component, so it does
|
|
894
|
+
* not know its props. The text is kept in local state and only parsed on change, so a
|
|
895
|
+
* half-typed object does not blow away what was there — the parent never sees an invalid
|
|
896
|
+
* value, and the error says so instead of the field silently reverting.
|
|
897
|
+
*/
|
|
898
|
+
function JsonProps({ value, onChange }: { value: Widget["componentProps"]; onChange: (v: Widget["componentProps"]) => void }) {
|
|
899
|
+
const [text, setText] = useState(() => (value ? JSON.stringify(value, null, 2) : ""));
|
|
900
|
+
const [bad, setBad] = useState(false);
|
|
901
|
+
return (
|
|
902
|
+
<label className="flex flex-col gap-1.5">
|
|
903
|
+
<span className="text-caption text-fg-subtle">Props (JSON object, optional)</span>
|
|
904
|
+
<textarea
|
|
905
|
+
className={`${CONTROL} h-auto min-h-24 py-2.5 font-mono text-[12px]`}
|
|
906
|
+
value={text}
|
|
907
|
+
onChange={(e) => {
|
|
908
|
+
const next = e.target.value;
|
|
909
|
+
setText(next);
|
|
910
|
+
if (next.trim() === "") { setBad(false); onChange(undefined); return; }
|
|
911
|
+
try {
|
|
912
|
+
const parsed: unknown = JSON.parse(next);
|
|
913
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { setBad(true); return; }
|
|
914
|
+
setBad(false);
|
|
915
|
+
onChange(parsed as Widget["componentProps"]);
|
|
916
|
+
} catch {
|
|
917
|
+
setBad(true);
|
|
918
|
+
}
|
|
919
|
+
}}
|
|
920
|
+
/>
|
|
921
|
+
{bad ? <span className="text-caption text-danger">Not a JSON object — the last valid value is what will be saved.</span> : null}
|
|
922
|
+
</label>
|
|
923
|
+
);
|
|
924
|
+
}
|