@pramen/cms-editor 0.0.14
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 +40 -0
- package/dist/index.html +12 -0
- package/dist/main.c43qkeax.js +339 -0
- package/package.json +28 -0
- package/src/api.ts +106 -0
- package/src/app.tsx +688 -0
- package/src/fields.tsx +212 -0
- package/src/main.tsx +11 -0
- package/src/styles.ts +108 -0
- package/src/types.ts +118 -0
package/src/app.tsx
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// The CMS visual editor. Left: page list. Center: region canvas (blocks per region, add
|
|
2
|
+
// from a palette filtered by allowedTypes, reorder, remove, select). Right: the selected
|
|
3
|
+
// block's schema-driven field form, or page settings (Settings / SEO / Workflow / i18n /
|
|
4
|
+
// Audit). All mutations go through the semantic CMS handlers (validation/publish enforced).
|
|
5
|
+
|
|
6
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { Api, ApiError, loadConfig, saveConfig, type Config } from "./api";
|
|
8
|
+
import { FieldForm } from "./fields";
|
|
9
|
+
import type { AssembledPage, AuditEntry, BlockType, ContentType, FieldDefinition, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
10
|
+
|
|
11
|
+
export function App() {
|
|
12
|
+
const [cfg, setCfg] = useState<Config>(loadConfig());
|
|
13
|
+
const api = useMemo(() => new Api(cfg), [cfg]);
|
|
14
|
+
const configured = cfg.baseUrl && cfg.token;
|
|
15
|
+
if (!configured) return <Setup cfg={cfg} onSave={(c) => { saveConfig(c); setCfg(c); }} />;
|
|
16
|
+
return <Editor api={api} cfg={cfg} onReconfigure={() => setCfg({ ...cfg, token: "" })} />;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Setup({ cfg, onSave }: { cfg: Config; onSave: (c: Config) => void }) {
|
|
20
|
+
const [c, setC] = useState(cfg);
|
|
21
|
+
return (
|
|
22
|
+
<div className="setup">
|
|
23
|
+
<h1>
|
|
24
|
+
pramen <span className="dim">· cms editor</span>
|
|
25
|
+
</h1>
|
|
26
|
+
<p className="muted">Point at your Worker and paste an editor/reviewer JWT. CORS must allow this origin (`CORS_ORIGINS`).</p>
|
|
27
|
+
<label className="field">
|
|
28
|
+
<span className="lbl">Worker base URL</span>
|
|
29
|
+
<input value={c.baseUrl} onChange={(e) => setC({ ...c, baseUrl: e.target.value })} placeholder="https://your-worker.workers.dev" />
|
|
30
|
+
</label>
|
|
31
|
+
<label className="field">
|
|
32
|
+
<span className="lbl">Tenant</span>
|
|
33
|
+
<input value={c.tenant} onChange={(e) => setC({ ...c, tenant: e.target.value })} placeholder="main" />
|
|
34
|
+
</label>
|
|
35
|
+
<label className="field">
|
|
36
|
+
<span className="lbl">Bearer token (editor or reviewer)</span>
|
|
37
|
+
<input value={c.token} onChange={(e) => setC({ ...c, token: e.target.value })} placeholder="eyJ…" />
|
|
38
|
+
</label>
|
|
39
|
+
<button className="primary" onClick={() => onSave(c)} disabled={!c.baseUrl || !c.token}>
|
|
40
|
+
Connect
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfigure: () => void }) {
|
|
47
|
+
const [pages, setPages] = useState<Page[]>([]);
|
|
48
|
+
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
49
|
+
const [current, setCurrent] = useState<Page | null>(null);
|
|
50
|
+
const [view, setView] = useState<"pages" | "media">("pages");
|
|
51
|
+
const [err, setErr] = useState("");
|
|
52
|
+
|
|
53
|
+
const refreshPages = useCallback(() => {
|
|
54
|
+
api.listPages().then(setPages).catch((e) => setErr(errMsg(e)));
|
|
55
|
+
}, [api]);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
refreshPages();
|
|
58
|
+
api.listBlockTypes().then(setBlockTypes).catch((e) => setErr(errMsg(e)));
|
|
59
|
+
}, [api, refreshPages]);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<div className="bar">
|
|
64
|
+
<span className="brand">
|
|
65
|
+
pramen <span className="dim">· cms</span>
|
|
66
|
+
</span>
|
|
67
|
+
<nav className="tabs" style={{ margin: 0 }}>
|
|
68
|
+
<button className={view === "pages" ? "on" : "ghost"} onClick={() => { setView("pages"); setCurrent(null); }}>
|
|
69
|
+
Pages
|
|
70
|
+
</button>
|
|
71
|
+
<button className={view === "media" ? "on" : "ghost"} onClick={() => setView("media")}>
|
|
72
|
+
Media
|
|
73
|
+
</button>
|
|
74
|
+
</nav>
|
|
75
|
+
{view === "pages" && current ? (
|
|
76
|
+
<span className="crumb">
|
|
77
|
+
<a onClick={() => setCurrent(null)}>Pages</a> / <b>{current.title}</b>
|
|
78
|
+
</span>
|
|
79
|
+
) : null}
|
|
80
|
+
<span className="grow" />
|
|
81
|
+
<span className="muted">{cfg.tenant}</span>
|
|
82
|
+
<button className="ghost sm" onClick={onReconfigure}>
|
|
83
|
+
sign out
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
{err ? <div className="banner err">{err}</div> : null}
|
|
87
|
+
{view === "media" ? (
|
|
88
|
+
<MediaLibrary api={api} onError={setErr} />
|
|
89
|
+
) : current ? (
|
|
90
|
+
<PageEditor api={api} page={current} blockTypes={blockTypes} onBack={() => { setCurrent(null); refreshPages(); }} onChange={(p) => setCurrent(p)} />
|
|
91
|
+
) : (
|
|
92
|
+
<PageList api={api} pages={pages} blockTypes={blockTypes} onOpen={setCurrent} onCreated={refreshPages} onError={setErr} />
|
|
93
|
+
)}
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
99
|
+
const [creating, setCreating] = useState(false);
|
|
100
|
+
return (
|
|
101
|
+
<div style={{ padding: 20, maxWidth: 780, margin: "0 auto" }}>
|
|
102
|
+
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
|
|
103
|
+
<h2 style={{ margin: 0, flex: 1 }}>Pages</h2>
|
|
104
|
+
<button className="primary" onClick={() => setCreating(true)}>
|
|
105
|
+
+ New page
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
<div className="list">
|
|
109
|
+
{pages.map((p) => (
|
|
110
|
+
<div className="row" key={p.id} onClick={() => onOpen(p)}>
|
|
111
|
+
<span className="grow">{p.title}</span>
|
|
112
|
+
<span className="muted">/{p.slug}</span>
|
|
113
|
+
<span className="muted">{p.locale}</span>
|
|
114
|
+
<span className={`pill ${p.status}`}>{p.status}</span>
|
|
115
|
+
</div>
|
|
116
|
+
))}
|
|
117
|
+
{pages.length === 0 ? <p className="muted">No pages yet. {blockTypes.length === 0 ? "Define block types + a content type first (via the API/admin)." : "Create one."}</p> : null}
|
|
118
|
+
</div>
|
|
119
|
+
{creating ? <CreatePage api={api} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); onCreated(); }} onError={onError} /> : null}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: () => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
125
|
+
const [cts, setCts] = useState<ContentType[]>([]);
|
|
126
|
+
const [typeId, setTypeId] = useState("");
|
|
127
|
+
const [title, setTitle] = useState("");
|
|
128
|
+
const [slug, setSlug] = useState("");
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
api.listContentTypes().then((r) => { setCts(r); if (r[0]) setTypeId(r[0].id); }).catch((e) => onError(errMsg(e)));
|
|
131
|
+
}, [api, onError]);
|
|
132
|
+
const create = async () => {
|
|
133
|
+
try {
|
|
134
|
+
await api.call("createPage", { typeId, title, slug: slug || slugify(title) });
|
|
135
|
+
onCreated();
|
|
136
|
+
} catch (e) {
|
|
137
|
+
onError(errMsg(e));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
return (
|
|
141
|
+
<div className="scrim" onClick={onClose}>
|
|
142
|
+
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
143
|
+
<h2>New page</h2>
|
|
144
|
+
<label className="field">
|
|
145
|
+
<span className="lbl">Content type</span>
|
|
146
|
+
<select value={typeId} onChange={(e) => setTypeId(e.target.value)}>
|
|
147
|
+
{cts.map((c) => (
|
|
148
|
+
<option key={c.id} value={c.id}>
|
|
149
|
+
{c.name}
|
|
150
|
+
</option>
|
|
151
|
+
))}
|
|
152
|
+
</select>
|
|
153
|
+
</label>
|
|
154
|
+
<label className="field">
|
|
155
|
+
<span className="lbl">Title</span>
|
|
156
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
|
157
|
+
</label>
|
|
158
|
+
<label className="field">
|
|
159
|
+
<span className="lbl">Slug</span>
|
|
160
|
+
<input value={slug} onChange={(e) => setSlug(e.target.value)} placeholder={slugify(title)} />
|
|
161
|
+
</label>
|
|
162
|
+
<div style={{ textAlign: "right", marginTop: 10 }}>
|
|
163
|
+
<button className="ghost" onClick={onClose}>
|
|
164
|
+
cancel
|
|
165
|
+
</button>{" "}
|
|
166
|
+
<button className="primary" onClick={create} disabled={!typeId || !title}>
|
|
167
|
+
Create
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function PageEditor({ api, page, blockTypes, onBack, onChange }: { api: Api; page: Page; blockTypes: BlockType[]; onBack: () => void; onChange: (p: Page) => void }) {
|
|
176
|
+
const [ct, setCt] = useState<ContentType | null>(null);
|
|
177
|
+
const [assembled, setAssembled] = useState<AssembledPage | null>(null);
|
|
178
|
+
const [selected, setSelected] = useState<RenderedBlock | null>(null);
|
|
179
|
+
const [tab, setTab] = useState<"settings" | "seo" | "workflow" | "i18n" | "audit">("settings");
|
|
180
|
+
const [err, setErr] = useState("");
|
|
181
|
+
const [msg, setMsg] = useState("");
|
|
182
|
+
|
|
183
|
+
const btBySlug = useMemo(() => new Map(blockTypes.map((b) => [b.slug, b])), [blockTypes]);
|
|
184
|
+
|
|
185
|
+
const reload = useCallback(async () => {
|
|
186
|
+
try {
|
|
187
|
+
const [c, a] = await Promise.all([api.getContentType(page.typeId), api.getPagePreview(page.slug, page.locale)]);
|
|
188
|
+
setCt(c ?? null);
|
|
189
|
+
setAssembled(a);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
setErr(errMsg(e));
|
|
192
|
+
}
|
|
193
|
+
}, [api, page.typeId, page.slug, page.locale]);
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
reload();
|
|
196
|
+
setSelected(null);
|
|
197
|
+
}, [reload]);
|
|
198
|
+
|
|
199
|
+
const regions: RegionDefinition[] = ct?.regions ?? [];
|
|
200
|
+
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
|
|
201
|
+
|
|
202
|
+
const addBlock = async (region: string, slug: string) => {
|
|
203
|
+
try {
|
|
204
|
+
await api.call("addBlock", { pageId: page.id, blockTypeSlug: slug, region, fields: {} });
|
|
205
|
+
await reload();
|
|
206
|
+
flash("block added");
|
|
207
|
+
} catch (e) {
|
|
208
|
+
setErr(errMsg(e));
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const removeBlock = async (b: RenderedBlock) => {
|
|
212
|
+
try {
|
|
213
|
+
await api.call("removeBlock", { pageBlockId: b.id });
|
|
214
|
+
if (selected?.id === b.id) setSelected(null);
|
|
215
|
+
await reload();
|
|
216
|
+
} catch (e) {
|
|
217
|
+
setErr(errMsg(e));
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const reorder = async (region: string, order: string[]) => {
|
|
221
|
+
try {
|
|
222
|
+
await api.call("reorderRegion", { pageId: page.id, region, order });
|
|
223
|
+
await reload();
|
|
224
|
+
} catch (e) {
|
|
225
|
+
setErr(errMsg(e));
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="layout">
|
|
231
|
+
<div className="side">
|
|
232
|
+
<button className="ghost sm" onClick={onBack}>
|
|
233
|
+
← all pages
|
|
234
|
+
</button>
|
|
235
|
+
<div className="sect">Regions</div>
|
|
236
|
+
{regions.map((r) => (
|
|
237
|
+
<div key={r.name} className="row" style={{ cursor: "default" }}>
|
|
238
|
+
<span className="grow">{r.label ?? r.name}</span>
|
|
239
|
+
<span className="muted">{(assembled?.regions[r.name] ?? []).length}</span>
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
<div className="sect">Status</div>
|
|
243
|
+
<div className="row" style={{ cursor: "default" }}>
|
|
244
|
+
<span className="grow">{page.title}</span>
|
|
245
|
+
<span className={`pill ${page.status}`}>{page.status}</span>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="canvas">
|
|
250
|
+
{err ? <div className="banner err">{err}</div> : null}
|
|
251
|
+
{msg ? <div className="banner ok">{msg}</div> : null}
|
|
252
|
+
{regions.map((r) => {
|
|
253
|
+
const blocks = assembled?.regions[r.name] ?? [];
|
|
254
|
+
const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
|
|
255
|
+
return (
|
|
256
|
+
<div className="region" key={r.name}>
|
|
257
|
+
<h3>
|
|
258
|
+
{r.label ?? r.name}
|
|
259
|
+
{r.allowedTypes ? <span className="allow">only: {r.allowedTypes.join(", ")}</span> : null}
|
|
260
|
+
</h3>
|
|
261
|
+
{blocks.map((b, i) => (
|
|
262
|
+
<div className={`block ${selected?.id === b.id ? "selected" : ""}`} key={b.id} onClick={() => setSelected(b)}>
|
|
263
|
+
<div className="bhead">
|
|
264
|
+
<span className="btype">{b.block_type}</span>
|
|
265
|
+
{b.is_shared ? <span className="shared">shared</span> : null}
|
|
266
|
+
<span className="grow muted">{summarize(b.fields)}</span>
|
|
267
|
+
<button className="ghost sm" onClick={(e) => { e.stopPropagation(); reorderMove(blocks, r.name, i, -1, reorder); }}>
|
|
268
|
+
↑
|
|
269
|
+
</button>
|
|
270
|
+
<button className="ghost sm" onClick={(e) => { e.stopPropagation(); reorderMove(blocks, r.name, i, 1, reorder); }}>
|
|
271
|
+
↓
|
|
272
|
+
</button>
|
|
273
|
+
<button className="ghost sm danger" onClick={(e) => { e.stopPropagation(); removeBlock(b); }}>
|
|
274
|
+
✕
|
|
275
|
+
</button>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
))}
|
|
279
|
+
<div className="palette">
|
|
280
|
+
{allowed.map((slug) => (
|
|
281
|
+
<button key={slug} className="sm" onClick={() => addBlock(r.name, slug)}>
|
|
282
|
+
+ {btBySlug.get(slug)?.name ?? slug}
|
|
283
|
+
</button>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
})}
|
|
289
|
+
{regions.length === 0 ? <p className="muted">This page's content type has no regions.</p> : null}
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<div className="inspect">
|
|
293
|
+
{selected ? (
|
|
294
|
+
<BlockInspector api={api} block={selected} blockType={btBySlug.get(selected.block_type)} onClose={() => setSelected(null)} onSaved={reload} onError={setErr} />
|
|
295
|
+
) : (
|
|
296
|
+
<>
|
|
297
|
+
<div className="tabs">
|
|
298
|
+
{(["settings", "seo", "workflow", "i18n", "audit"] as const).map((t) => (
|
|
299
|
+
<button key={t} className={tab === t ? "on" : ""} onClick={() => setTab(t)}>
|
|
300
|
+
{t}
|
|
301
|
+
</button>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
{tab === "settings" ? <Settings api={api} page={page} onSaved={onChange} onError={setErr} /> : null}
|
|
305
|
+
{tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
|
|
306
|
+
{tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
|
|
307
|
+
{tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
308
|
+
{tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
|
|
309
|
+
</>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function BlockInspector({ api, block, blockType, onClose, onSaved, onError }: { api: Api; block: RenderedBlock; blockType: BlockType | undefined; onClose: () => void; onSaved: () => void; onError: (s: string) => void }) {
|
|
317
|
+
const [fields, setFields] = useState<Record<string, unknown>>({});
|
|
318
|
+
const [busy, setBusy] = useState(false);
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
// Load RAW fields (media as ids) for editing.
|
|
321
|
+
api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId: block.block_id }).then((b) => setFields(b?.fields ?? {})).catch((e) => onError(errMsg(e)));
|
|
322
|
+
}, [api, block.block_id, onError]);
|
|
323
|
+
const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
|
|
324
|
+
const save = async () => {
|
|
325
|
+
setBusy(true);
|
|
326
|
+
try {
|
|
327
|
+
await api.call("updateBlock", { blockId: block.block_id, fields });
|
|
328
|
+
onSaved();
|
|
329
|
+
} catch (e) {
|
|
330
|
+
onError(errMsg(e));
|
|
331
|
+
} finally {
|
|
332
|
+
setBusy(false);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
return (
|
|
336
|
+
<div>
|
|
337
|
+
<div style={{ display: "flex", alignItems: "center" }}>
|
|
338
|
+
<b className="btype" style={{ flex: 1, color: "var(--spring)", fontFamily: "var(--mono)" }}>
|
|
339
|
+
{block.block_type}
|
|
340
|
+
</b>
|
|
341
|
+
<button className="ghost sm" onClick={onClose}>
|
|
342
|
+
done
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
{schema.length === 0 ? <p className="muted">This block type has no fields.</p> : <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />}
|
|
346
|
+
<button className="primary" style={{ marginTop: 12, width: "100%" }} onClick={save} disabled={busy}>
|
|
347
|
+
Save block
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function Settings({ api, page, onSaved, onError }: { api: Api; page: Page; onSaved: (p: Page) => void; onError: (s: string) => void }) {
|
|
354
|
+
// The core CMS API doesn't expose a general page-rename handler; keep this read-only +
|
|
355
|
+
// link the essentials. (A future `updatePage` handler would back editable title/slug.)
|
|
356
|
+
void api;
|
|
357
|
+
void onSaved;
|
|
358
|
+
void onError;
|
|
359
|
+
return (
|
|
360
|
+
<div className="kv">
|
|
361
|
+
<span>Title</span>
|
|
362
|
+
<span>{page.title}</span>
|
|
363
|
+
<span>Slug</span>
|
|
364
|
+
<span>/{page.slug}</span>
|
|
365
|
+
<span>Locale</span>
|
|
366
|
+
<span>{page.locale}</span>
|
|
367
|
+
<span>Status</span>
|
|
368
|
+
<span>{page.status}</span>
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function SeoPanel({ api, page, onError }: { api: Api; page: Page; onError: (s: string) => void }) {
|
|
374
|
+
const [f, setF] = useState({ metaTitle: page.metaTitle ?? "", metaDescription: page.metaDescription ?? "", canonicalUrl: page.canonicalUrl ?? "", robots: page.robots ?? "", ogTitle: page.ogTitle ?? "", ogDescription: page.ogDescription ?? "" });
|
|
375
|
+
const [ok, setOk] = useState(false);
|
|
376
|
+
const save = async () => {
|
|
377
|
+
try {
|
|
378
|
+
await api.call("updatePageSeo", { pageId: page.id, ...f });
|
|
379
|
+
setOk(true);
|
|
380
|
+
setTimeout(() => setOk(false), 1500);
|
|
381
|
+
} catch (e) {
|
|
382
|
+
onError(errMsg(e));
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const F = (k: keyof typeof f, label: string, area = false) => (
|
|
386
|
+
<label className="field">
|
|
387
|
+
<span className="lbl">{label}</span>
|
|
388
|
+
{area ? <textarea value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })} /> : <input value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })} />}
|
|
389
|
+
</label>
|
|
390
|
+
);
|
|
391
|
+
return (
|
|
392
|
+
<div>
|
|
393
|
+
<div className="sect">SEO</div>
|
|
394
|
+
{ok ? <div className="banner ok">saved</div> : null}
|
|
395
|
+
{F("metaTitle", "Meta title")}
|
|
396
|
+
{F("metaDescription", "Meta description", true)}
|
|
397
|
+
{F("canonicalUrl", "Canonical URL")}
|
|
398
|
+
{F("robots", "Robots (e.g. noindex)")}
|
|
399
|
+
{F("ogTitle", "OG title")}
|
|
400
|
+
{F("ogDescription", "OG description", true)}
|
|
401
|
+
<button className="primary" style={{ width: "100%" }} onClick={save}>
|
|
402
|
+
Save SEO
|
|
403
|
+
</button>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function Workflow({ api, page, onChanged, onError }: { api: Api; page: Page; onChanged: (p: Page) => void; onError: (s: string) => void }) {
|
|
409
|
+
const act = async (name: string, input?: unknown) => {
|
|
410
|
+
try {
|
|
411
|
+
const r = await api.call<{ page?: Page }>(name, { pageId: page.id, ...(input as object) });
|
|
412
|
+
if (r?.page) onChanged(r.page);
|
|
413
|
+
} catch (e) {
|
|
414
|
+
onError(errMsg(e));
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
return (
|
|
418
|
+
<div>
|
|
419
|
+
<div className="sect">Workflow</div>
|
|
420
|
+
<p className="kv">
|
|
421
|
+
<span>Status</span>
|
|
422
|
+
<span className={`pill ${page.status}`}>{page.status}</span>
|
|
423
|
+
</p>
|
|
424
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
425
|
+
<button onClick={() => act("submitForReview")}>Submit for review</button>
|
|
426
|
+
<button className="primary" onClick={() => act("approve")}>
|
|
427
|
+
Approve (publish)
|
|
428
|
+
</button>
|
|
429
|
+
<button onClick={() => act("reject")}>Reject</button>
|
|
430
|
+
<button onClick={() => act("publishPage")}>Publish directly</button>
|
|
431
|
+
<button className="ghost" onClick={() => act("unpublishPage")}>
|
|
432
|
+
Unpublish
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
<p className="muted" style={{ marginTop: 10 }}>Submit is editor-gated; approve/publish are reviewer-gated.</p>
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function I18n({ api, page, onError }: { api: Api; page: Page; onError: (s: string) => void }) {
|
|
441
|
+
const [translations, setTranslations] = useState<{ id: string; locale: string; slug: string; status: string }[]>([]);
|
|
442
|
+
const [locale, setLocale] = useState("");
|
|
443
|
+
const refresh = useCallback(() => api.call<typeof translations>("listTranslations", { pageId: page.id }).then(setTranslations).catch((e) => onError(errMsg(e))), [api, page.id, onError]);
|
|
444
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
445
|
+
const create = async () => {
|
|
446
|
+
try {
|
|
447
|
+
await api.call("createTranslation", { pageId: page.id, locale });
|
|
448
|
+
setLocale("");
|
|
449
|
+
refresh();
|
|
450
|
+
} catch (e) {
|
|
451
|
+
onError(errMsg(e));
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
return (
|
|
455
|
+
<div>
|
|
456
|
+
<div className="sect">Translations</div>
|
|
457
|
+
<div className="list">
|
|
458
|
+
{translations.map((t) => (
|
|
459
|
+
<div className="row" key={t.id} style={{ cursor: "default" }}>
|
|
460
|
+
<span className="grow">{t.locale}</span>
|
|
461
|
+
<span className="muted">/{t.slug}</span>
|
|
462
|
+
<span className={`pill ${t.status}`}>{t.status}</span>
|
|
463
|
+
</div>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
|
467
|
+
<input value={locale} onChange={(e) => setLocale(e.target.value)} placeholder="locale (e.g. cs)" />
|
|
468
|
+
<button onClick={create} disabled={!locale}>
|
|
469
|
+
add
|
|
470
|
+
</button>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function AuditLog({ api, pageId, onError }: { api: Api; pageId: string; onError: (s: string) => void }) {
|
|
477
|
+
const [rows, setRows] = useState<AuditEntry[]>([]);
|
|
478
|
+
useEffect(() => {
|
|
479
|
+
api.listPageAudit(pageId).then(setRows).catch((e) => onError(errMsg(e)));
|
|
480
|
+
}, [api, pageId, onError]);
|
|
481
|
+
return (
|
|
482
|
+
<div>
|
|
483
|
+
<div className="sect">Audit trail</div>
|
|
484
|
+
<div className="list">
|
|
485
|
+
{rows.map((a) => (
|
|
486
|
+
<div className="row" key={a.id} style={{ cursor: "default", fontSize: 12 }}>
|
|
487
|
+
<span className="btype">{a.action}</span>
|
|
488
|
+
<span className="grow muted">
|
|
489
|
+
{a.fromStatus} → {a.toStatus}
|
|
490
|
+
</span>
|
|
491
|
+
<span className="muted">{a.actor ?? "system"}</span>
|
|
492
|
+
</div>
|
|
493
|
+
))}
|
|
494
|
+
{rows.length === 0 ? <p className="muted">No history yet.</p> : null}
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// --- media library (a top-level view: browse, upload, edit alt, delete) ---
|
|
501
|
+
const PAGE_SIZE = 60;
|
|
502
|
+
|
|
503
|
+
function MediaLibrary({ api, onError }: { api: Api; onError: (s: string) => void }) {
|
|
504
|
+
const [media, setMedia] = useState<Media[]>([]);
|
|
505
|
+
const [offset, setOffset] = useState(0);
|
|
506
|
+
const [hasMore, setHasMore] = useState(false);
|
|
507
|
+
const [selected, setSelected] = useState<Media | null>(null);
|
|
508
|
+
const [busy, setBusy] = useState(false);
|
|
509
|
+
|
|
510
|
+
const load = useCallback(
|
|
511
|
+
(off: number) => {
|
|
512
|
+
api
|
|
513
|
+
.listMedia(PAGE_SIZE, off)
|
|
514
|
+
.then((rows) => {
|
|
515
|
+
setMedia((prev) => (off === 0 ? rows : [...prev, ...rows]));
|
|
516
|
+
setHasMore(rows.length === PAGE_SIZE);
|
|
517
|
+
setOffset(off + rows.length);
|
|
518
|
+
})
|
|
519
|
+
.catch((e) => onError(errMsg(e)));
|
|
520
|
+
},
|
|
521
|
+
[api, onError],
|
|
522
|
+
);
|
|
523
|
+
useEffect(() => { load(0); }, [load]);
|
|
524
|
+
|
|
525
|
+
const upload = async (files: FileList | null) => {
|
|
526
|
+
if (!files?.length) return;
|
|
527
|
+
setBusy(true);
|
|
528
|
+
onError("");
|
|
529
|
+
try {
|
|
530
|
+
for (const f of Array.from(files)) await api.uploadMedia(f);
|
|
531
|
+
load(0); // refresh from the top
|
|
532
|
+
} catch (e) {
|
|
533
|
+
onError(errMsg(e));
|
|
534
|
+
} finally {
|
|
535
|
+
setBusy(false);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<div className="media-lib">
|
|
541
|
+
<div className="media-toolbar">
|
|
542
|
+
<h2 style={{ margin: 0, flex: 1 }}>Media</h2>
|
|
543
|
+
<label className={`btn primary${busy ? " disabled" : ""}`}>
|
|
544
|
+
{busy ? "Uploading…" : "+ Upload"}
|
|
545
|
+
<input type="file" multiple hidden disabled={busy} onChange={(e) => { upload(e.target.files); e.target.value = ""; }} />
|
|
546
|
+
</label>
|
|
547
|
+
</div>
|
|
548
|
+
{media.length === 0 ? (
|
|
549
|
+
<p className="muted">No media yet. Upload images to use them in blocks and SEO.</p>
|
|
550
|
+
) : (
|
|
551
|
+
<div className="media-grid">
|
|
552
|
+
{media.map((m) => (
|
|
553
|
+
<div key={m.id} className={`media-cell${selected?.id === m.id ? " sel" : ""}`} onClick={() => setSelected(m)}>
|
|
554
|
+
{isImage(m) ? <img src={api.resolve(`/media/${m.file.key}`)} alt={m.alt ?? ""} /> : <div className="ext">{ext(m)}</div>}
|
|
555
|
+
<div className="fn">{m.file.filename ?? m.id}</div>
|
|
556
|
+
</div>
|
|
557
|
+
))}
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
{hasMore ? (
|
|
561
|
+
<div style={{ textAlign: "center", marginTop: 14 }}>
|
|
562
|
+
<button className="sm" onClick={() => load(offset)}>
|
|
563
|
+
Load more
|
|
564
|
+
</button>
|
|
565
|
+
</div>
|
|
566
|
+
) : null}
|
|
567
|
+
{selected ? (
|
|
568
|
+
<MediaDetail
|
|
569
|
+
api={api}
|
|
570
|
+
media={selected}
|
|
571
|
+
onClose={() => setSelected(null)}
|
|
572
|
+
onSaved={(m) => { setSelected(m); setMedia((prev) => prev.map((x) => (x.id === m.id ? m : x))); }}
|
|
573
|
+
onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); }}
|
|
574
|
+
onError={onError}
|
|
575
|
+
/>
|
|
576
|
+
) : null}
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function MediaDetail({ api, media, onClose, onSaved, onDeleted, onError }: { api: Api; media: Media; onClose: () => void; onSaved: (m: Media) => void; onDeleted: (id: string) => void; onError: (s: string) => void }) {
|
|
582
|
+
const [alt, setAlt] = useState(media.alt ?? "");
|
|
583
|
+
const [busy, setBusy] = useState(false);
|
|
584
|
+
useEffect(() => setAlt(media.alt ?? ""), [media]);
|
|
585
|
+
const url = api.resolve(`/media/${media.file.key}`);
|
|
586
|
+
|
|
587
|
+
const save = async () => {
|
|
588
|
+
setBusy(true);
|
|
589
|
+
try {
|
|
590
|
+
onSaved(await api.updateMedia(media.id, alt || null));
|
|
591
|
+
} catch (e) {
|
|
592
|
+
onError(errMsg(e));
|
|
593
|
+
} finally {
|
|
594
|
+
setBusy(false);
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const del = async () => {
|
|
598
|
+
if (!confirm("Delete this file permanently? If a block or page still references it, that image will break — this cannot be undone.")) return;
|
|
599
|
+
setBusy(true);
|
|
600
|
+
try {
|
|
601
|
+
await api.deleteMedia(media.id);
|
|
602
|
+
onDeleted(media.id);
|
|
603
|
+
} catch (e) {
|
|
604
|
+
onError(errMsg(e));
|
|
605
|
+
} finally {
|
|
606
|
+
setBusy(false);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
<div className="scrim" onClick={onClose}>
|
|
612
|
+
<div className="modal media-detail" onClick={(e) => e.stopPropagation()}>
|
|
613
|
+
<h2>{media.file.filename ?? "Media"}</h2>
|
|
614
|
+
{isImage(media) ? <img src={url} alt={media.alt ?? ""} /> : <div className="ext lg">{ext(media)}</div>}
|
|
615
|
+
<label className="field">
|
|
616
|
+
<span className="lbl">Alt text (for accessibility & SEO)</span>
|
|
617
|
+
<input value={alt} onChange={(e) => setAlt(e.target.value)} placeholder="Describe the image…" />
|
|
618
|
+
</label>
|
|
619
|
+
<div className="kv">
|
|
620
|
+
<span>Type</span>
|
|
621
|
+
<span>{media.file.contentType ?? "—"}</span>
|
|
622
|
+
<span>Size</span>
|
|
623
|
+
<span>{fmtBytes(media.file.size)}</span>
|
|
624
|
+
<span>Uploaded</span>
|
|
625
|
+
<span>{media.file.uploadedAt ? new Date(media.file.uploadedAt).toLocaleString() : (media.createdAt ?? "—")}</span>
|
|
626
|
+
<span>URL</span>
|
|
627
|
+
<span>
|
|
628
|
+
<a href={url} target="_blank" rel="noreferrer">
|
|
629
|
+
/media/{media.file.key}
|
|
630
|
+
</a>
|
|
631
|
+
</span>
|
|
632
|
+
</div>
|
|
633
|
+
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
|
|
634
|
+
<button className="primary" onClick={save} disabled={busy || alt === (media.alt ?? "")}>
|
|
635
|
+
Save
|
|
636
|
+
</button>
|
|
637
|
+
<button className="sm" onClick={() => navigator.clipboard?.writeText(url)}>
|
|
638
|
+
Copy URL
|
|
639
|
+
</button>
|
|
640
|
+
<span className="grow" />
|
|
641
|
+
<button className="ghost danger" onClick={del} disabled={busy}>
|
|
642
|
+
Delete
|
|
643
|
+
</button>
|
|
644
|
+
<button className="ghost" onClick={onClose}>
|
|
645
|
+
Close
|
|
646
|
+
</button>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// --- helpers ---
|
|
654
|
+
function isImage(m: Media): boolean {
|
|
655
|
+
return (m.file.contentType ?? "").startsWith("image/");
|
|
656
|
+
}
|
|
657
|
+
function ext(m: Media): string {
|
|
658
|
+
const fromType = (m.file.contentType ?? "").split("/")[1];
|
|
659
|
+
const fromName = m.file.filename?.split(".").pop();
|
|
660
|
+
return (fromName ?? fromType ?? "file").slice(0, 5).toUpperCase();
|
|
661
|
+
}
|
|
662
|
+
function fmtBytes(n?: number): string {
|
|
663
|
+
if (!n || n <= 0) return "—";
|
|
664
|
+
const u = ["B", "KB", "MB", "GB"];
|
|
665
|
+
let i = 0;
|
|
666
|
+
let v = n;
|
|
667
|
+
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
|
668
|
+
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function errMsg(e: unknown): string {
|
|
672
|
+
if (e instanceof ApiError) return e.message;
|
|
673
|
+
return e instanceof Error ? e.message : String(e);
|
|
674
|
+
}
|
|
675
|
+
function slugify(s: string): string {
|
|
676
|
+
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
677
|
+
}
|
|
678
|
+
function summarize(fields: Record<string, unknown>): string {
|
|
679
|
+
const first = Object.values(fields).find((v) => typeof v === "string" && v);
|
|
680
|
+
return typeof first === "string" ? (first.length > 48 ? first.slice(0, 48) + "…" : first) : "";
|
|
681
|
+
}
|
|
682
|
+
function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: number, reorder: (region: string, order: string[]) => void) {
|
|
683
|
+
const j = i + d;
|
|
684
|
+
if (j < 0 || j >= blocks.length) return;
|
|
685
|
+
const ids = blocks.map((b) => b.id);
|
|
686
|
+
[ids[i], ids[j]] = [ids[j], ids[i]];
|
|
687
|
+
reorder(region, ids);
|
|
688
|
+
}
|