@pramen/cms-editor 0.0.21 → 0.0.23

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/src/app.tsx DELETED
@@ -1,969 +0,0 @@
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
- type View = "pages" | "media" | "users" | "settings";
47
- interface Me { userId?: string; roles?: string[]; [k: string]: unknown }
48
-
49
- function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfigure: () => void }) {
50
- const [pages, setPages] = useState<Page[]>([]);
51
- const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
52
- const [current, setCurrent] = useState<Page | null>(null);
53
- const [view, setView] = useState<View>("pages");
54
- const [err, setErr] = useState("");
55
- const [me, setMe] = useState<Me | null>(null);
56
-
57
- const refreshPages = useCallback(() => {
58
- api.listPages().then(setPages).catch((e) => setErr(errMsg(e)));
59
- }, [api]);
60
- useEffect(() => {
61
- refreshPages();
62
- api.listBlockTypes().then(setBlockTypes).catch((e) => setErr(errMsg(e)));
63
- // `me` gates the Users tab — a failing call is fine (leaves it undefined).
64
- api.call<Me>("me").then(setMe).catch(() => setMe({}));
65
- }, [api, refreshPages]);
66
-
67
- const isAdmin = (me?.roles ?? []).includes("admin");
68
- const go = (v: View) => { setView(v); setCurrent(null); };
69
-
70
- return (
71
- <>
72
- <div className="bar">
73
- <span className="brand">
74
- pramen <span className="dim">· cms</span>
75
- </span>
76
- {view === "pages" && current ? (
77
- <span className="crumb">
78
- <a onClick={() => setCurrent(null)}>Pages</a> / <b>{current.title}</b>
79
- </span>
80
- ) : null}
81
- <span className="grow" />
82
- <nav className="tabs nav" style={{ margin: 0 }}>
83
- <button className={view === "pages" ? "on" : ""} onClick={() => go("pages")}>Pages</button>
84
- <button className={view === "media" ? "on" : ""} onClick={() => go("media")}>Media</button>
85
- {isAdmin ? (
86
- <button className={view === "users" ? "on" : ""} onClick={() => go("users")}>Users</button>
87
- ) : null}
88
- <button className={view === "settings" ? "on" : ""} onClick={() => go("settings")}>Settings</button>
89
- </nav>
90
- <span className="muted" style={{ marginLeft: 12 }}>{cfg.tenant}</span>
91
- <button className="ghost sm" onClick={onReconfigure}>
92
- sign out
93
- </button>
94
- </div>
95
- {err ? <div className="banner err">{err}</div> : null}
96
- {view === "media" ? (
97
- <MediaLibrary api={api} onError={setErr} />
98
- ) : view === "users" ? (
99
- <UsersView api={api} me={me} onError={setErr} />
100
- ) : view === "settings" ? (
101
- <SettingsView api={api} cfg={cfg} me={me} onSignOut={onReconfigure} onError={setErr} />
102
- ) : current ? (
103
- <PageEditor api={api} page={current} blockTypes={blockTypes} onBack={() => { setCurrent(null); refreshPages(); }} onChange={(p) => setCurrent(p)} />
104
- ) : (
105
- <PageList api={api} pages={pages} blockTypes={blockTypes} onOpen={setCurrent} onCreated={refreshPages} onError={setErr} />
106
- )}
107
- </>
108
- );
109
- }
110
-
111
- function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
112
- const [creating, setCreating] = useState(false);
113
- return (
114
- <>
115
- <div className="hero">
116
- <h1 className="hero-h">
117
- <span className="lead">Pages</span>
118
- <span className="em">{pages.length === 0 ? "None yet" : pages.length === 1 ? "1 page total" : `${pages.length} pages total`}</span>
119
- </h1>
120
- <div className="cta">
121
- <span className="cta-text">
122
- Let&apos;s <span className="em">create</span> something
123
- </span>
124
- <button className="primary" onClick={() => setCreating(true)}>
125
- + New page
126
- </button>
127
- </div>
128
- </div>
129
- <div className="list-wrap">
130
- <div className="list">
131
- {pages.map((p) => (
132
- <div className="row" key={p.id} onClick={() => onOpen(p)}>
133
- <span className="grow">{p.title}</span>
134
- <span className="muted">/{p.slug}</span>
135
- <span className="muted">{p.locale}</span>
136
- <span className={`pill ${p.status}`}>{p.status}</span>
137
- </div>
138
- ))}
139
- {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}
140
- </div>
141
- </div>
142
- {creating ? <CreatePage api={api} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); onCreated(); }} onError={onError} /> : null}
143
- </>
144
- );
145
- }
146
-
147
- function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: () => void; onCreated: () => void; onError: (s: string) => void }) {
148
- const [cts, setCts] = useState<ContentType[]>([]);
149
- const [typeId, setTypeId] = useState("");
150
- const [title, setTitle] = useState("");
151
- const [slug, setSlug] = useState("");
152
- useEffect(() => {
153
- api.listContentTypes().then((r) => { setCts(r); if (r[0]) setTypeId(r[0].id); }).catch((e) => onError(errMsg(e)));
154
- }, [api, onError]);
155
- const create = async () => {
156
- try {
157
- await api.call("createPage", { typeId, title, slug: slug || slugify(title) });
158
- onCreated();
159
- } catch (e) {
160
- onError(errMsg(e));
161
- }
162
- };
163
- return (
164
- <div className="scrim" onClick={onClose}>
165
- <div className="modal" onClick={(e) => e.stopPropagation()}>
166
- <h2>
167
- Create a <span className="dim">new page</span> and define the essentials<span className="dim">.</span>
168
- </h2>
169
- <label className="field">
170
- <span className="lbl">Content type</span>
171
- <select value={typeId} onChange={(e) => setTypeId(e.target.value)}>
172
- {cts.map((c) => (
173
- <option key={c.id} value={c.id}>
174
- {c.name}
175
- </option>
176
- ))}
177
- </select>
178
- </label>
179
- <label className="field">
180
- <span className="lbl">Title</span>
181
- <input value={title} onChange={(e) => setTitle(e.target.value)} />
182
- </label>
183
- <label className="field">
184
- <span className="lbl">Slug</span>
185
- <input value={slug} onChange={(e) => setSlug(e.target.value)} placeholder={slugify(title)} />
186
- </label>
187
- <div style={{ textAlign: "right", marginTop: 10 }}>
188
- <button className="ghost" onClick={onClose}>
189
- cancel
190
- </button>{" "}
191
- <button className="primary" onClick={create} disabled={!typeId || !title}>
192
- Create
193
- </button>
194
- </div>
195
- </div>
196
- </div>
197
- );
198
- }
199
-
200
- function PageEditor({ api, page, blockTypes, onBack, onChange }: { api: Api; page: Page; blockTypes: BlockType[]; onBack: () => void; onChange: (p: Page) => void }) {
201
- const [ct, setCt] = useState<ContentType | null>(null);
202
- const [assembled, setAssembled] = useState<AssembledPage | null>(null);
203
- const [selected, setSelected] = useState<RenderedBlock | null>(null);
204
- const [tab, setTab] = useState<"settings" | "seo" | "workflow" | "i18n" | "audit">("settings");
205
- const [err, setErr] = useState("");
206
- const [msg, setMsg] = useState("");
207
-
208
- const btBySlug = useMemo(() => new Map(blockTypes.map((b) => [b.slug, b])), [blockTypes]);
209
-
210
- const reload = useCallback(async () => {
211
- try {
212
- const [c, a] = await Promise.all([api.getContentType(page.typeId), api.getPagePreview(page.slug, page.locale)]);
213
- setCt(c ?? null);
214
- setAssembled(a);
215
- } catch (e) {
216
- setErr(errMsg(e));
217
- }
218
- }, [api, page.typeId, page.slug, page.locale]);
219
- useEffect(() => {
220
- reload();
221
- setSelected(null);
222
- }, [reload]);
223
-
224
- const regions: RegionDefinition[] = ct?.regions ?? [];
225
- const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
226
-
227
- const addBlock = async (region: string, slug: string) => {
228
- try {
229
- await api.call("addBlock", { pageId: page.id, blockTypeSlug: slug, region, fields: {} });
230
- await reload();
231
- flash("block added");
232
- } catch (e) {
233
- setErr(errMsg(e));
234
- }
235
- };
236
- const removeBlock = async (b: RenderedBlock) => {
237
- try {
238
- await api.call("removeBlock", { pageBlockId: b.id });
239
- if (selected?.id === b.id) setSelected(null);
240
- await reload();
241
- } catch (e) {
242
- setErr(errMsg(e));
243
- }
244
- };
245
- const reorder = async (region: string, order: string[]) => {
246
- try {
247
- await api.call("reorderRegion", { pageId: page.id, region, order });
248
- await reload();
249
- } catch (e) {
250
- setErr(errMsg(e));
251
- }
252
- };
253
-
254
- return (
255
- <div className="layout">
256
- <div className="side">
257
- <button className="ghost sm" onClick={onBack}>
258
- ← all pages
259
- </button>
260
- <div className="sect">Regions</div>
261
- {regions.map((r) => (
262
- <div key={r.name} className="row" style={{ cursor: "default" }}>
263
- <span className="grow">{r.label ?? r.name}</span>
264
- <span className="muted">{(assembled?.regions[r.name] ?? []).length}</span>
265
- </div>
266
- ))}
267
- <div className="sect">Status</div>
268
- <div className="row" style={{ cursor: "default" }}>
269
- <span className="grow">{page.title}</span>
270
- <span className={`pill ${page.status}`}>{page.status}</span>
271
- </div>
272
- </div>
273
-
274
- <div className="canvas">
275
- {err ? <div className="banner err">{err}</div> : null}
276
- {msg ? <div className="banner ok">{msg}</div> : null}
277
- {regions.map((r) => {
278
- const blocks = assembled?.regions[r.name] ?? [];
279
- const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
280
- return (
281
- <div className="region" key={r.name}>
282
- <h3>
283
- {r.label ?? r.name}
284
- {r.allowedTypes ? <span className="allow">only: {r.allowedTypes.join(", ")}</span> : null}
285
- </h3>
286
- {blocks.map((b, i) => (
287
- <div className={`block ${selected?.id === b.id ? "selected" : ""}`} key={b.id} onClick={() => setSelected(b)}>
288
- <div className="bhead">
289
- <span className="btype">{b.block_type}</span>
290
- {b.is_shared ? <span className="shared">shared</span> : null}
291
- <span className="grow muted">{summarize(b.fields)}</span>
292
- <button className="ghost sm" onClick={(e) => { e.stopPropagation(); reorderMove(blocks, r.name, i, -1, reorder); }}>
293
-
294
- </button>
295
- <button className="ghost sm" onClick={(e) => { e.stopPropagation(); reorderMove(blocks, r.name, i, 1, reorder); }}>
296
-
297
- </button>
298
- <button className="ghost sm danger" onClick={(e) => { e.stopPropagation(); removeBlock(b); }}>
299
-
300
- </button>
301
- </div>
302
- </div>
303
- ))}
304
- <div className="palette">
305
- {allowed.map((slug) => (
306
- <button key={slug} className="sm" onClick={() => addBlock(r.name, slug)}>
307
- + {btBySlug.get(slug)?.name ?? slug}
308
- </button>
309
- ))}
310
- </div>
311
- </div>
312
- );
313
- })}
314
- {regions.length === 0 ? <p className="muted">This page's content type has no regions.</p> : null}
315
- </div>
316
-
317
- <div className="inspect">
318
- {selected ? (
319
- <BlockInspector api={api} block={selected} blockType={btBySlug.get(selected.block_type)} onClose={() => setSelected(null)} onSaved={reload} onError={setErr} />
320
- ) : (
321
- <>
322
- <div className="tabs">
323
- {(["settings", "seo", "workflow", "i18n", "audit"] as const).map((t) => (
324
- <button key={t} className={tab === t ? "on" : ""} onClick={() => setTab(t)}>
325
- {t}
326
- </button>
327
- ))}
328
- </div>
329
- {tab === "settings" ? <Settings api={api} page={page} onSaved={onChange} onError={setErr} /> : null}
330
- {tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
331
- {tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
332
- {tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
333
- {tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
334
- </>
335
- )}
336
- </div>
337
- </div>
338
- );
339
- }
340
-
341
- function BlockInspector({ api, block, blockType, onClose, onSaved, onError }: { api: Api; block: RenderedBlock; blockType: BlockType | undefined; onClose: () => void; onSaved: () => void; onError: (s: string) => void }) {
342
- const [fields, setFields] = useState<Record<string, unknown>>({});
343
- const [busy, setBusy] = useState(false);
344
- useEffect(() => {
345
- // Load RAW fields (media as ids) for editing.
346
- api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId: block.block_id }).then((b) => setFields(b?.fields ?? {})).catch((e) => onError(errMsg(e)));
347
- }, [api, block.block_id, onError]);
348
- const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
349
- const save = async () => {
350
- setBusy(true);
351
- try {
352
- await api.call("updateBlock", { blockId: block.block_id, fields });
353
- onSaved();
354
- } catch (e) {
355
- onError(errMsg(e));
356
- } finally {
357
- setBusy(false);
358
- }
359
- };
360
- return (
361
- <div>
362
- <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
363
- <span className="btype">{block.block_type}</span>
364
- <span style={{ flex: 1 }} />
365
- <button className="ghost sm" onClick={onClose}>
366
- done
367
- </button>
368
- </div>
369
- {schema.length === 0 ? <p className="muted">This block type has no fields.</p> : <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />}
370
- <button className="primary" style={{ marginTop: 12, width: "100%" }} onClick={save} disabled={busy}>
371
- Save block
372
- </button>
373
- </div>
374
- );
375
- }
376
-
377
- function Settings({ api, page, onSaved, onError }: { api: Api; page: Page; onSaved: (p: Page) => void; onError: (s: string) => void }) {
378
- // The core CMS API doesn't expose a general page-rename handler; keep this read-only +
379
- // link the essentials. (A future `updatePage` handler would back editable title/slug.)
380
- void api;
381
- void onSaved;
382
- void onError;
383
- return (
384
- <div className="kv">
385
- <span>Title</span>
386
- <span>{page.title}</span>
387
- <span>Slug</span>
388
- <span>/{page.slug}</span>
389
- <span>Locale</span>
390
- <span>{page.locale}</span>
391
- <span>Status</span>
392
- <span>{page.status}</span>
393
- </div>
394
- );
395
- }
396
-
397
- function SeoPanel({ api, page, onError }: { api: Api; page: Page; onError: (s: string) => void }) {
398
- const [f, setF] = useState({ metaTitle: page.metaTitle ?? "", metaDescription: page.metaDescription ?? "", canonicalUrl: page.canonicalUrl ?? "", robots: page.robots ?? "", ogTitle: page.ogTitle ?? "", ogDescription: page.ogDescription ?? "" });
399
- const [ok, setOk] = useState(false);
400
- const save = async () => {
401
- try {
402
- await api.call("updatePageSeo", { pageId: page.id, ...f });
403
- setOk(true);
404
- setTimeout(() => setOk(false), 1500);
405
- } catch (e) {
406
- onError(errMsg(e));
407
- }
408
- };
409
- const F = (k: keyof typeof f, label: string, area = false) => (
410
- <label className="field">
411
- <span className="lbl">{label}</span>
412
- {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 })} />}
413
- </label>
414
- );
415
- return (
416
- <div>
417
- <div className="sect">SEO</div>
418
- {ok ? <div className="banner ok">saved</div> : null}
419
- {F("metaTitle", "Meta title")}
420
- {F("metaDescription", "Meta description", true)}
421
- {F("canonicalUrl", "Canonical URL")}
422
- {F("robots", "Robots (e.g. noindex)")}
423
- {F("ogTitle", "OG title")}
424
- {F("ogDescription", "OG description", true)}
425
- <button className="primary" style={{ width: "100%" }} onClick={save}>
426
- Save SEO
427
- </button>
428
- </div>
429
- );
430
- }
431
-
432
- function Workflow({ api, page, onChanged, onError }: { api: Api; page: Page; onChanged: (p: Page) => void; onError: (s: string) => void }) {
433
- const act = async (name: string, input?: unknown) => {
434
- try {
435
- const r = await api.call<{ page?: Page }>(name, { pageId: page.id, ...(input as object) });
436
- if (r?.page) onChanged(r.page);
437
- } catch (e) {
438
- onError(errMsg(e));
439
- }
440
- };
441
- return (
442
- <div>
443
- <div className="sect">Workflow</div>
444
- <p className="kv">
445
- <span>Status</span>
446
- <span className={`pill ${page.status}`}>{page.status}</span>
447
- </p>
448
- <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
449
- <button onClick={() => act("submitForReview")}>Submit for review</button>
450
- <button className="primary" onClick={() => act("approve")}>
451
- Approve (publish)
452
- </button>
453
- <button onClick={() => act("reject")}>Reject</button>
454
- <button onClick={() => act("publishPage")}>Publish directly</button>
455
- <button className="ghost" onClick={() => act("unpublishPage")}>
456
- Unpublish
457
- </button>
458
- </div>
459
- <p className="muted" style={{ marginTop: 10 }}>Submit is editor-gated; approve/publish are reviewer-gated.</p>
460
- </div>
461
- );
462
- }
463
-
464
- function I18n({ api, page, onError }: { api: Api; page: Page; onError: (s: string) => void }) {
465
- const [translations, setTranslations] = useState<{ id: string; locale: string; slug: string; status: string }[]>([]);
466
- const [locale, setLocale] = useState("");
467
- const refresh = useCallback(() => api.call<typeof translations>("listTranslations", { pageId: page.id }).then(setTranslations).catch((e) => onError(errMsg(e))), [api, page.id, onError]);
468
- useEffect(() => { refresh(); }, [refresh]);
469
- const create = async () => {
470
- try {
471
- await api.call("createTranslation", { pageId: page.id, locale });
472
- setLocale("");
473
- refresh();
474
- } catch (e) {
475
- onError(errMsg(e));
476
- }
477
- };
478
- return (
479
- <div>
480
- <div className="sect">Translations</div>
481
- <div className="list">
482
- {translations.map((t) => (
483
- <div className="row" key={t.id} style={{ cursor: "default" }}>
484
- <span className="grow">{t.locale}</span>
485
- <span className="muted">/{t.slug}</span>
486
- <span className={`pill ${t.status}`}>{t.status}</span>
487
- </div>
488
- ))}
489
- </div>
490
- <div style={{ display: "flex", gap: 6, marginTop: 8 }}>
491
- <input value={locale} onChange={(e) => setLocale(e.target.value)} placeholder="locale (e.g. cs)" />
492
- <button onClick={create} disabled={!locale}>
493
- add
494
- </button>
495
- </div>
496
- </div>
497
- );
498
- }
499
-
500
- function AuditLog({ api, pageId, onError }: { api: Api; pageId: string; onError: (s: string) => void }) {
501
- const [rows, setRows] = useState<AuditEntry[]>([]);
502
- useEffect(() => {
503
- api.listPageAudit(pageId).then(setRows).catch((e) => onError(errMsg(e)));
504
- }, [api, pageId, onError]);
505
- return (
506
- <div>
507
- <div className="sect">Audit trail</div>
508
- <div className="list">
509
- {rows.map((a) => (
510
- <div className="row" key={a.id} style={{ cursor: "default", fontSize: 12 }}>
511
- <span className="btype">{a.action}</span>
512
- <span className="grow muted">
513
- {a.fromStatus} → {a.toStatus}
514
- </span>
515
- <span className="muted">{a.actor ?? "system"}</span>
516
- </div>
517
- ))}
518
- {rows.length === 0 ? <p className="muted">No history yet.</p> : null}
519
- </div>
520
- </div>
521
- );
522
- }
523
-
524
- // --- media library (a top-level view: browse, upload, edit alt, delete) ---
525
- const PAGE_SIZE = 60;
526
-
527
- function MediaLibrary({ api, onError }: { api: Api; onError: (s: string) => void }) {
528
- const [media, setMedia] = useState<Media[]>([]);
529
- const [offset, setOffset] = useState(0);
530
- const [hasMore, setHasMore] = useState(false);
531
- const [selected, setSelected] = useState<Media | null>(null);
532
- const [busy, setBusy] = useState(false);
533
-
534
- const load = useCallback(
535
- (off: number) => {
536
- api
537
- .listMedia(PAGE_SIZE, off)
538
- .then((rows) => {
539
- setMedia((prev) => (off === 0 ? rows : [...prev, ...rows]));
540
- setHasMore(rows.length === PAGE_SIZE);
541
- setOffset(off + rows.length);
542
- })
543
- .catch((e) => onError(errMsg(e)));
544
- },
545
- [api, onError],
546
- );
547
- useEffect(() => { load(0); }, [load]);
548
-
549
- const upload = async (files: FileList | null) => {
550
- if (!files?.length) return;
551
- setBusy(true);
552
- onError("");
553
- try {
554
- for (const f of Array.from(files)) await api.uploadMedia(f);
555
- load(0); // refresh from the top
556
- } catch (e) {
557
- onError(errMsg(e));
558
- } finally {
559
- setBusy(false);
560
- }
561
- };
562
-
563
- return (
564
- <>
565
- <div className="hero">
566
- <h1 className="hero-h">
567
- <span className="lead">Media</span>
568
- <span className="em">{media.length === 0 ? "None yet" : media.length === 1 ? "1 file" : `${media.length}${hasMore ? "+" : ""} files`}</span>
569
- </h1>
570
- <div className="cta">
571
- <span className="cta-text">
572
- Let&apos;s <span className="em">upload</span> something
573
- </span>
574
- <label className={`btn primary${busy ? " disabled" : ""}`}>
575
- {busy ? "Uploading…" : "+ Upload"}
576
- <input type="file" multiple hidden disabled={busy} onChange={(e) => { upload(e.target.files); e.target.value = ""; }} />
577
- </label>
578
- </div>
579
- </div>
580
- <div className="media-lib">
581
- {media.length === 0 ? (
582
- <p className="muted">No media yet. Upload images to use them in blocks and SEO.</p>
583
- ) : (
584
- <div className="media-grid">
585
- {media.map((m) => (
586
- <div key={m.id} className={`media-cell${selected?.id === m.id ? " sel" : ""}`} onClick={() => setSelected(m)}>
587
- {isImage(m) ? <img src={api.resolve(`/media/${m.file.key}`)} alt={m.alt ?? ""} /> : <div className="ext">{ext(m)}</div>}
588
- <div className="fn">{m.file.filename ?? m.id}</div>
589
- </div>
590
- ))}
591
- </div>
592
- )}
593
- {hasMore ? (
594
- <div style={{ textAlign: "center", marginTop: 14 }}>
595
- <button className="sm" onClick={() => load(offset)}>
596
- Load more
597
- </button>
598
- </div>
599
- ) : null}
600
- {selected ? (
601
- <MediaDetail
602
- api={api}
603
- media={selected}
604
- onClose={() => setSelected(null)}
605
- onSaved={(m) => { setSelected(m); setMedia((prev) => prev.map((x) => (x.id === m.id ? m : x))); }}
606
- onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); }}
607
- onError={onError}
608
- />
609
- ) : null}
610
- </div>
611
- </>
612
- );
613
- }
614
-
615
- 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 }) {
616
- const [alt, setAlt] = useState(media.alt ?? "");
617
- const [busy, setBusy] = useState(false);
618
- useEffect(() => setAlt(media.alt ?? ""), [media]);
619
- const url = api.resolve(`/media/${media.file.key}`);
620
-
621
- const save = async () => {
622
- setBusy(true);
623
- try {
624
- onSaved(await api.updateMedia(media.id, alt || null));
625
- } catch (e) {
626
- onError(errMsg(e));
627
- } finally {
628
- setBusy(false);
629
- }
630
- };
631
- const del = async () => {
632
- if (!confirm("Delete this file permanently? If a block or page still references it, that image will break — this cannot be undone.")) return;
633
- setBusy(true);
634
- try {
635
- await api.deleteMedia(media.id);
636
- onDeleted(media.id);
637
- } catch (e) {
638
- onError(errMsg(e));
639
- } finally {
640
- setBusy(false);
641
- }
642
- };
643
-
644
- return (
645
- <div className="scrim" onClick={onClose}>
646
- <div className="modal media-detail" onClick={(e) => e.stopPropagation()}>
647
- <h2>
648
- <span className="dim">Media</span> {media.file.filename ?? ""}
649
- </h2>
650
- {isImage(media) ? <img src={url} alt={media.alt ?? ""} /> : <div className="ext lg">{ext(media)}</div>}
651
- <label className="field">
652
- <span className="lbl">Alt text (for accessibility &amp; SEO)</span>
653
- <input value={alt} onChange={(e) => setAlt(e.target.value)} placeholder="Describe the image…" />
654
- </label>
655
- <div className="kv">
656
- <span>Type</span>
657
- <span>{media.file.contentType ?? "—"}</span>
658
- <span>Size</span>
659
- <span>{fmtBytes(media.file.size)}</span>
660
- <span>Uploaded</span>
661
- <span>{media.file.uploadedAt ? new Date(media.file.uploadedAt).toLocaleString() : (media.createdAt ?? "—")}</span>
662
- <span>URL</span>
663
- <span>
664
- <a href={url} target="_blank" rel="noreferrer">
665
- /media/{media.file.key}
666
- </a>
667
- </span>
668
- </div>
669
- <div style={{ display: "flex", gap: 8, marginTop: 14 }}>
670
- <button className="primary" onClick={save} disabled={busy || alt === (media.alt ?? "")}>
671
- Save
672
- </button>
673
- <button className="sm" onClick={() => navigator.clipboard?.writeText(url)}>
674
- Copy URL
675
- </button>
676
- <span className="grow" />
677
- <button className="ghost danger" onClick={del} disabled={busy}>
678
- Delete
679
- </button>
680
- <button className="ghost" onClick={onClose}>
681
- Close
682
- </button>
683
- </div>
684
- </div>
685
- </div>
686
- );
687
- }
688
-
689
- // --- helpers ---
690
- function isImage(m: Media): boolean {
691
- return (m.file.contentType ?? "").startsWith("image/");
692
- }
693
- function ext(m: Media): string {
694
- const fromType = (m.file.contentType ?? "").split("/")[1];
695
- const fromName = m.file.filename?.split(".").pop();
696
- return (fromName ?? fromType ?? "file").slice(0, 5).toUpperCase();
697
- }
698
- function fmtBytes(n?: number): string {
699
- if (!n || n <= 0) return "—";
700
- const u = ["B", "KB", "MB", "GB"];
701
- let i = 0;
702
- let v = n;
703
- while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
704
- return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
705
- }
706
-
707
- function errMsg(e: unknown): string {
708
- if (e instanceof ApiError) return e.message;
709
- return e instanceof Error ? e.message : String(e);
710
- }
711
- function slugify(s: string): string {
712
- return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
713
- }
714
- function summarize(fields: Record<string, unknown>): string {
715
- const first = Object.values(fields).find((v) => typeof v === "string" && v);
716
- return typeof first === "string" ? (first.length > 48 ? first.slice(0, 48) + "…" : first) : "";
717
- }
718
- function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: number, reorder: (region: string, order: string[]) => void) {
719
- const j = i + d;
720
- if (j < 0 || j >= blocks.length) return;
721
- const ids = blocks.map((b) => b.id);
722
- [ids[i], ids[j]] = [ids[j], ids[i]];
723
- reorder(region, ids);
724
- }
725
-
726
- // --- users management (admin) ------------------------------------------------
727
-
728
- interface UserRow {
729
- username: string;
730
- email?: string | null;
731
- roles?: string[] | string;
732
- active?: boolean | number | null;
733
- createdAt?: number | null;
734
- }
735
-
736
- function rolesOf(u: UserRow): string[] {
737
- const r = u.roles;
738
- if (Array.isArray(r)) return r.filter((x): x is string => typeof x === "string");
739
- if (typeof r === "string") { try { const j = JSON.parse(r); return Array.isArray(j) ? j : []; } catch { return []; } }
740
- return [];
741
- }
742
-
743
- function UsersView({ api, me, onError }: { api: Api; me: Me | null; onError: (s: string) => void }) {
744
- const [users, setUsers] = useState<UserRow[]>([]);
745
- const [inviting, setInviting] = useState(false);
746
- const [busy, setBusy] = useState<string>("");
747
-
748
- const refresh = useCallback(() => {
749
- api.call<UserRow[]>("listUsers", { limit: 200 }).then(setUsers).catch((e) => onError(errMsg(e)));
750
- }, [api, onError]);
751
- useEffect(() => { refresh(); }, [refresh]);
752
-
753
- const setRoles = async (u: UserRow, roles: string[]) => {
754
- setBusy(u.username);
755
- try { await api.call("setUserRoles", { username: u.username, roles }); refresh(); }
756
- catch (e) { onError(errMsg(e)); }
757
- finally { setBusy(""); }
758
- };
759
- const setActive = async (u: UserRow, active: boolean) => {
760
- setBusy(u.username);
761
- try { await api.call("setUserActive", { username: u.username, active }); refresh(); }
762
- catch (e) { onError(errMsg(e)); }
763
- finally { setBusy(""); }
764
- };
765
- const del = async (u: UserRow) => {
766
- if (!confirm(`Delete user ${u.username}? This cannot be undone.`)) return;
767
- setBusy(u.username);
768
- try { await api.call("deleteUser", { username: u.username }); refresh(); }
769
- catch (e) { onError(errMsg(e)); }
770
- finally { setBusy(""); }
771
- };
772
-
773
- return (
774
- <>
775
- <div className="hero">
776
- <h1 className="hero-h">
777
- <span className="lead">Users</span>
778
- <span className="em">{users.length === 0 ? "None yet" : users.length === 1 ? "1 account" : `${users.length} accounts`}</span>
779
- </h1>
780
- <div className="cta">
781
- <span className="cta-text">Let&apos;s <span className="em">invite</span> someone</span>
782
- <button className="primary" onClick={() => setInviting(true)}>+ Invite</button>
783
- </div>
784
- </div>
785
- <div className="list-wrap">
786
- <div className="list">
787
- {users.map((u) => {
788
- const roles = rolesOf(u);
789
- const isMe = me?.userId === u.username;
790
- const active = u.active === undefined || u.active === null ? true : Boolean(Number(u.active));
791
- return (
792
- <div className="row" key={u.username} style={{ cursor: "default", alignItems: "flex-start", flexWrap: "wrap" }}>
793
- <div style={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 200, gap: 2 }}>
794
- <span style={{ fontWeight: 600 }}>{u.username}{isMe ? <span className="muted" style={{ marginLeft: 6, fontWeight: 400 }}>(you)</span> : null}</span>
795
- {u.email && u.email !== u.username ? <span className="muted" style={{ fontSize: 12 }}>{u.email}</span> : null}
796
- {u.createdAt ? <span className="muted" style={{ fontSize: 11 }}>joined {new Date(Number(u.createdAt)).toLocaleDateString()}</span> : null}
797
- </div>
798
- <RolesInput value={roles} disabled={busy === u.username} onSave={(next) => setRoles(u, next)} />
799
- <span className={`pill ${active ? "published" : "archived"}`}>{active ? "active" : "inactive"}</span>
800
- <button className="sm ghost" disabled={busy === u.username || isMe} onClick={() => setActive(u, !active)}>
801
- {active ? "Deactivate" : "Activate"}
802
- </button>
803
- <button className="sm ghost danger" disabled={busy === u.username || isMe} onClick={() => del(u)}>
804
- Delete
805
- </button>
806
- </div>
807
- );
808
- })}
809
- {users.length === 0 ? <p className="muted">No users yet. Invite someone to get started.</p> : null}
810
- </div>
811
- </div>
812
- {inviting ? <InviteUser api={api} onClose={() => setInviting(false)} onInvited={() => { setInviting(false); refresh(); }} onError={onError} /> : null}
813
- </>
814
- );
815
- }
816
-
817
- function RolesInput({ value, disabled, onSave }: { value: string[]; disabled: boolean; onSave: (roles: string[]) => void }) {
818
- const [text, setText] = useState(value.join(", "));
819
- const [editing, setEditing] = useState(false);
820
- useEffect(() => { setText(value.join(", ")); }, [value]);
821
- const commit = () => {
822
- setEditing(false);
823
- const next = text.split(",").map((s) => s.trim()).filter(Boolean);
824
- if (next.length === 0) { setText(value.join(", ")); return; }
825
- if (next.length === value.length && next.every((r, i) => r === value[i])) return;
826
- onSave(next);
827
- };
828
- if (editing) {
829
- return (
830
- <input
831
- autoFocus
832
- style={{ width: 220 }}
833
- value={text}
834
- disabled={disabled}
835
- onChange={(e) => setText(e.target.value)}
836
- onBlur={commit}
837
- onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setText(value.join(", ")); setEditing(false); } }}
838
- />
839
- );
840
- }
841
- return (
842
- <span style={{ display: "flex", gap: 4, flexWrap: "wrap", cursor: "pointer" }} onClick={() => setEditing(true)} title="Click to edit">
843
- {value.length === 0 ? <span className="pill">no roles</span> : value.map((r) => <span key={r} className={`pill ${r === "admin" ? "published" : ""}`}>{r}</span>)}
844
- </span>
845
- );
846
- }
847
-
848
- function InviteUser({ api, onClose, onInvited, onError }: { api: Api; onClose: () => void; onInvited: () => void; onError: (s: string) => void }) {
849
- const [email, setEmail] = useState("");
850
- const [roles, setRoles] = useState("editor");
851
- const [busy, setBusy] = useState(false);
852
- const invite = async () => {
853
- setBusy(true);
854
- try {
855
- const rs = roles.split(",").map((s) => s.trim()).filter(Boolean);
856
- await api.call("inviteUser", { email, roles: rs });
857
- onInvited();
858
- } catch (e) {
859
- onError(errMsg(e));
860
- } finally {
861
- setBusy(false);
862
- }
863
- };
864
- return (
865
- <div className="scrim" onClick={onClose}>
866
- <div className="modal" onClick={(e) => e.stopPropagation()}>
867
- <h2>
868
- Invite an <span className="dim">editor</span> or teammate
869
- </h2>
870
- <p className="muted" style={{ marginTop: -8 }}>They&apos;ll get a one-time magic link that logs them in and creates their account.</p>
871
- <label className="field">
872
- <span className="lbl">Email</span>
873
- <input value={email} type="email" autoFocus onChange={(e) => setEmail(e.target.value)} placeholder="them@example.com" />
874
- </label>
875
- <label className="field">
876
- <span className="lbl">Roles <span className="muted" style={{ fontWeight: 400 }}>(comma-separated — e.g. editor, reviewer, admin)</span></span>
877
- <input value={roles} onChange={(e) => setRoles(e.target.value)} placeholder="editor" />
878
- </label>
879
- <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 16 }}>
880
- <button className="ghost" onClick={onClose}>Cancel</button>
881
- <button className="primary" onClick={invite} disabled={busy || !email}>{busy ? "Sending…" : "Send invite"}</button>
882
- </div>
883
- </div>
884
- </div>
885
- );
886
- }
887
-
888
- // --- settings ----------------------------------------------------------------
889
-
890
- function SettingsView({ api, cfg, me, onSignOut, onError }: { api: Api; cfg: Config; me: Me | null; onSignOut: () => void; onError: (s: string) => void }) {
891
- return (
892
- <>
893
- <div className="hero">
894
- <h1 className="hero-h">
895
- <span className="lead">Settings</span>
896
- <span className="em">{me?.userId ?? "your account"}</span>
897
- </h1>
898
- </div>
899
- <div className="list-wrap" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
900
- <MyAccountCard api={api} me={me} onError={onError} onSignOut={onSignOut} />
901
- <AboutCard cfg={cfg} me={me} />
902
- </div>
903
- </>
904
- );
905
- }
906
-
907
- function MyAccountCard({ api, me, onError, onSignOut }: { api: Api; me: Me | null; onError: (s: string) => void; onSignOut: () => void }) {
908
- const [email, setEmail] = useState("");
909
- const [pwCurrent, setPwCurrent] = useState("");
910
- const [pwNew, setPwNew] = useState("");
911
- const [msg, setMsg] = useState("");
912
- const [busy, setBusy] = useState(false);
913
- const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
914
-
915
- const saveEmail = async () => {
916
- setBusy(true);
917
- try { await api.call("changeEmail", { email }); setEmail(""); flash("Contact email updated"); }
918
- catch (e) { onError(errMsg(e)); }
919
- finally { setBusy(false); }
920
- };
921
- const savePassword = async () => {
922
- setBusy(true);
923
- try { await api.call("changePassword", { currentPassword: pwCurrent, newPassword: pwNew }); setPwCurrent(""); setPwNew(""); flash("Password updated"); }
924
- catch (e) { onError(errMsg(e)); }
925
- finally { setBusy(false); }
926
- };
927
-
928
- return (
929
- <div className="inspect" style={{ background: "var(--surface-2)" }}>
930
- <div className="sect" style={{ marginTop: 0 }}>My account</div>
931
- {msg ? <div className="banner ok">{msg}</div> : null}
932
- <div className="kv" style={{ marginBottom: 16 }}>
933
- <span>Username</span><span>{me?.userId ?? "—"}</span>
934
- <span>Roles</span><span>{(me?.roles ?? []).join(", ") || "—"}</span>
935
- </div>
936
- <label className="field">
937
- <span className="lbl">Change contact email</span>
938
- <input value={email} type="email" onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
939
- </label>
940
- <button className="primary" onClick={saveEmail} disabled={busy || !email} style={{ width: "100%" }}>Save email</button>
941
- <div style={{ height: 20 }} />
942
- <label className="field">
943
- <span className="lbl">Current password</span>
944
- <input value={pwCurrent} type="password" onChange={(e) => setPwCurrent(e.target.value)} autoComplete="current-password" />
945
- </label>
946
- <label className="field">
947
- <span className="lbl">New password <span className="muted" style={{ fontWeight: 400 }}>(at least 8 characters)</span></span>
948
- <input value={pwNew} type="password" onChange={(e) => setPwNew(e.target.value)} autoComplete="new-password" />
949
- </label>
950
- <button className="primary" onClick={savePassword} disabled={busy || pwNew.length < 8 || pwCurrent.length === 0} style={{ width: "100%" }}>Change password</button>
951
- <div style={{ height: 24 }} />
952
- <button className="ghost danger" onClick={onSignOut} style={{ width: "100%" }}>Sign out</button>
953
- </div>
954
- );
955
- }
956
-
957
- function AboutCard({ cfg, me }: { cfg: Config; me: Me | null }) {
958
- return (
959
- <div className="inspect" style={{ background: "var(--surface-2)" }}>
960
- <div className="sect" style={{ marginTop: 0 }}>About</div>
961
- <div className="kv">
962
- <span>Tenant</span><span>{cfg.tenant || "main"}</span>
963
- <span>API</span><span style={{ wordBreak: "break-all" }}>{cfg.baseUrl}</span>
964
- <span>Signed in as</span><span>{me?.userId ?? "—"}</span>
965
- <span>Editor</span><span>pramen · cms-editor</span>
966
- </div>
967
- </div>
968
- );
969
- }