@pramen/cms-editor 0.0.21 → 0.0.22
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 +17 -0
- package/dist/app.css +1 -0
- package/dist/index.html +3 -1
- package/dist/main.54b274dt.js +251 -0
- package/dist/tokens.css +68 -0
- package/package.json +10 -2
- package/src/app-context.tsx +86 -0
- package/src/app.css +3 -0
- package/src/buzola.gen.ts +78 -0
- package/src/components.tsx +851 -0
- package/src/fields.tsx +83 -92
- package/src/main.tsx +13 -6
- package/src/routes/_404.tsx +20 -0
- package/src/routes/_layout.tsx +48 -0
- package/src/routes/home.tsx +34 -0
- package/src/routes/media.tsx +12 -0
- package/src/routes/page.tsx +62 -0
- package/src/routes/settings.tsx +12 -0
- package/src/routes/users.tsx +26 -0
- package/src/virtual-modules.d.ts +6 -0
- package/dist/main.43fpw937.js +0 -405
- package/src/app.tsx +0 -969
- package/src/styles.ts +0 -176
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
// Presentational + interactive pieces of the editor, shared across route files. The
|
|
2
|
+
// route modules under `routes/` are thin adapters: they pull `api`/`me`/`setError` from
|
|
3
|
+
// the app context and wire URL params + navigation into these components.
|
|
4
|
+
|
|
5
|
+
import { Button, Input, Textarea } from "@podoba/react";
|
|
6
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
7
|
+
import { Api, ApiError } from "./api";
|
|
8
|
+
import { FieldForm } from "./fields";
|
|
9
|
+
import type { Config } from "./api";
|
|
10
|
+
import type { Me } from "./app-context";
|
|
11
|
+
import type { AssembledPage, AuditEntry, BlockType, ContentType, FieldDefinition, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
12
|
+
|
|
13
|
+
export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
|
|
14
|
+
export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
|
|
15
|
+
|
|
16
|
+
// --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
|
|
17
|
+
|
|
18
|
+
const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
|
|
19
|
+
const WRAP = "mx-auto max-w-[1200px] px-7 pb-8 pt-2";
|
|
20
|
+
|
|
21
|
+
function Hero({ lead, em, children }: { lead: string; em: string; children?: ReactNode }) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="mx-auto grid max-w-[1200px] grid-cols-[1fr_auto] items-center gap-6 px-7 pb-6 pt-8 max-[820px]:grid-cols-1">
|
|
24
|
+
<h1 className="m-0 text-[56px] font-normal leading-[1.05] tracking-[-0.01em] max-[820px]:text-[40px]">
|
|
25
|
+
<span className="block text-fg-subtle">{lead}</span>
|
|
26
|
+
<span className="block text-fg">{em}</span>
|
|
27
|
+
</h1>
|
|
28
|
+
{children}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Cta({ text, em, children }: { text: string; em: string; children: ReactNode }) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex min-w-[360px] items-center gap-4 rounded-full bg-brand-green py-3 pl-7 pr-3 max-[820px]:min-w-0">
|
|
36
|
+
<span className="text-lg text-[#0f3a25]">
|
|
37
|
+
{text} <span className="font-semibold">{em}</span>
|
|
38
|
+
</span>
|
|
39
|
+
{children}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Section({ children }: { children: ReactNode }) {
|
|
45
|
+
return <div className="mb-2 mt-[18px] text-sm text-fg-subtle first:mt-0">{children}</div>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Pill({ status, children }: { status?: string; children: ReactNode }) {
|
|
49
|
+
const tone =
|
|
50
|
+
status === "published" || status === "active"
|
|
51
|
+
? "bg-brand-green text-[#0f3a25] border-transparent"
|
|
52
|
+
: status === "in_review" || status === "review"
|
|
53
|
+
? "bg-accent-yellow text-[#4a3512] border-transparent"
|
|
54
|
+
: status === "rejected" || status === "archived" || status === "inactive"
|
|
55
|
+
? "border-danger text-danger bg-surface-card"
|
|
56
|
+
: "border-border text-fg-muted bg-surface-card";
|
|
57
|
+
return <span className={`inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${tone}`}>{children}</span>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Modal({ onClose, wide, children }: { onClose: () => void; wide?: boolean; children: ReactNode }) {
|
|
61
|
+
return (
|
|
62
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(20,15,5,0.28)] p-6" onClick={onClose}>
|
|
63
|
+
<div
|
|
64
|
+
className={`max-h-[86vh] w-full overflow-auto rounded-panel border border-border bg-surface-card px-9 py-8 shadow-[0_24px_60px_rgba(30,20,10,0.12)] ${wide ? "max-w-[680px]" : "max-w-[520px]"}`}
|
|
65
|
+
onClick={(e) => e.stopPropagation()}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ModalTitle({ children }: { children: ReactNode }) {
|
|
74
|
+
return <h2 className="mb-5 text-[28px] font-normal leading-[1.15] text-fg">{children}</h2>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function KV({ children, className }: { children: ReactNode; className?: string }) {
|
|
78
|
+
return <div className={`grid grid-cols-[auto_1fr] gap-x-3.5 gap-y-2 text-[13px] text-fg-muted [&>span:nth-child(odd)]:text-fg-subtle ${className ?? ""}`}>{children}</div>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Card({ children }: { children: ReactNode }) {
|
|
82
|
+
return <div className="overflow-auto rounded-panel border border-border bg-surface-card p-6">{children}</div>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function Banner({ ok, children }: { ok?: boolean; children: ReactNode }) {
|
|
86
|
+
return (
|
|
87
|
+
<div className={`my-2 rounded-lg border px-3.5 py-2.5 text-[13px] ${ok ? "border-brand-green bg-brand-green/20 text-[#0f3a25]" : "border-danger bg-surface-card text-danger"}`}>{children}</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-subtle">{children}</span>;
|
|
92
|
+
|
|
93
|
+
// --- pages list --------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export function PageList({ api, pages, blockTypes, onOpen, onCreated, onError }: { api: Api; pages: Page[]; blockTypes: BlockType[]; onOpen: (p: Page) => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
96
|
+
const [creating, setCreating] = useState(false);
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<Hero lead="Pages" em={pages.length === 0 ? "None yet" : pages.length === 1 ? "1 page total" : `${pages.length} pages total`}>
|
|
100
|
+
<Cta text="Let's" em="create something">
|
|
101
|
+
<Button className="shrink-0" onPress={() => setCreating(true)}>+ New page</Button>
|
|
102
|
+
</Cta>
|
|
103
|
+
</Hero>
|
|
104
|
+
<div className={WRAP}>
|
|
105
|
+
<div className="flex flex-col gap-2">
|
|
106
|
+
{pages.map((p) => (
|
|
107
|
+
<div className={`${ROW} cursor-pointer hover:bg-surface-muted`} key={p.id} onClick={() => onOpen(p)}>
|
|
108
|
+
<span className="flex-1 truncate font-medium">{p.title}</span>
|
|
109
|
+
<span className="text-fg-subtle">/{p.slug}</span>
|
|
110
|
+
<span className="text-fg-subtle">{p.locale}</span>
|
|
111
|
+
<Pill status={p.status}>{p.status}</Pill>
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
{pages.length === 0 ? <p className="text-fg-subtle">No pages yet. {blockTypes.length === 0 ? "Define block types + a content type first (via the API/admin)." : "Create one."}</p> : null}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
{creating ? <CreatePage api={api} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); onCreated(); }} onError={onError} /> : null}
|
|
118
|
+
</>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function CreatePage({ api, onClose, onCreated, onError }: { api: Api; onClose: () => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
123
|
+
const [cts, setCts] = useState<ContentType[]>([]);
|
|
124
|
+
const [typeId, setTypeId] = useState("");
|
|
125
|
+
const [title, setTitle] = useState("");
|
|
126
|
+
const [slug, setSlug] = useState("");
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
api.listContentTypes().then((r) => { setCts(r); if (r[0]) setTypeId(r[0].id); }).catch((e) => onError(errMsg(e)));
|
|
129
|
+
}, [api, onError]);
|
|
130
|
+
const create = async () => {
|
|
131
|
+
try {
|
|
132
|
+
await api.call("createPage", { typeId, title, slug: slug || slugify(title) });
|
|
133
|
+
onCreated();
|
|
134
|
+
} catch (e) {
|
|
135
|
+
onError(errMsg(e));
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
return (
|
|
139
|
+
<Modal onClose={onClose}>
|
|
140
|
+
<ModalTitle>Create a <Dim>new page</Dim> and define the essentials<Dim>.</Dim></ModalTitle>
|
|
141
|
+
<div className="flex flex-col gap-4">
|
|
142
|
+
<label className="flex w-full flex-col gap-2">
|
|
143
|
+
<span className="text-sm font-medium text-fg">Content type</span>
|
|
144
|
+
<select className="h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none focus:border-brand-green" value={typeId} onChange={(e) => setTypeId(e.target.value)}>
|
|
145
|
+
{cts.map((c) => (
|
|
146
|
+
<option key={c.id} value={c.id}>{c.name}</option>
|
|
147
|
+
))}
|
|
148
|
+
</select>
|
|
149
|
+
</label>
|
|
150
|
+
<Input label="Title" value={title} onChange={setTitle} />
|
|
151
|
+
<Input label="Slug" value={slug} onChange={setSlug} placeholder={slugify(title)} />
|
|
152
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
153
|
+
<Button variant="ghost" onPress={onClose}>cancel</Button>
|
|
154
|
+
<Button onPress={create} isDisabled={!typeId || !title}>Create</Button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</Modal>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- page editor -------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export function PageEditor({ api, page, blockTypes, tab, onTab, onBack, onChange }: { api: Api; page: Page; blockTypes: BlockType[]; tab: InspectorTab; onTab: (t: InspectorTab) => void; onBack: () => void; onChange: (p: Page) => void }) {
|
|
164
|
+
const [ct, setCt] = useState<ContentType | null>(null);
|
|
165
|
+
const [assembled, setAssembled] = useState<AssembledPage | null>(null);
|
|
166
|
+
const [selected, setSelected] = useState<RenderedBlock | null>(null);
|
|
167
|
+
const [err, setErr] = useState("");
|
|
168
|
+
const [msg, setMsg] = useState("");
|
|
169
|
+
|
|
170
|
+
const btBySlug = useMemo(() => new Map(blockTypes.map((b) => [b.slug, b])), [blockTypes]);
|
|
171
|
+
|
|
172
|
+
const reload = useCallback(async () => {
|
|
173
|
+
try {
|
|
174
|
+
const [c, a] = await Promise.all([api.getContentType(page.typeId), api.getPagePreview(page.slug, page.locale)]);
|
|
175
|
+
setCt(c ?? null);
|
|
176
|
+
setAssembled(a);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
setErr(errMsg(e));
|
|
179
|
+
}
|
|
180
|
+
}, [api, page.typeId, page.slug, page.locale]);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
reload();
|
|
183
|
+
setSelected(null);
|
|
184
|
+
}, [reload]);
|
|
185
|
+
|
|
186
|
+
const regions: RegionDefinition[] = ct?.regions ?? [];
|
|
187
|
+
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
|
|
188
|
+
|
|
189
|
+
const addBlock = async (region: string, slug: string) => {
|
|
190
|
+
try {
|
|
191
|
+
await api.call("addBlock", { pageId: page.id, blockTypeSlug: slug, region, fields: {} });
|
|
192
|
+
await reload();
|
|
193
|
+
flash("block added");
|
|
194
|
+
} catch (e) {
|
|
195
|
+
setErr(errMsg(e));
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const removeBlock = async (b: RenderedBlock) => {
|
|
199
|
+
try {
|
|
200
|
+
await api.call("removeBlock", { pageBlockId: b.id });
|
|
201
|
+
if (selected?.id === b.id) setSelected(null);
|
|
202
|
+
await reload();
|
|
203
|
+
} catch (e) {
|
|
204
|
+
setErr(errMsg(e));
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const reorder = async (region: string, order: string[]) => {
|
|
208
|
+
try {
|
|
209
|
+
await api.call("reorderRegion", { pageId: page.id, region, order });
|
|
210
|
+
await reload();
|
|
211
|
+
} catch (e) {
|
|
212
|
+
setErr(errMsg(e));
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="grid min-h-[calc(100vh-68px)] grid-cols-[260px_1fr_400px] gap-5 px-7 pb-7 pt-2 max-[820px]:grid-cols-1">
|
|
218
|
+
<div className="overflow-auto rounded-panel bg-surface-muted p-[18px]">
|
|
219
|
+
<Button variant="ghost" size="sm" onPress={onBack}>← all pages</Button>
|
|
220
|
+
<Section>Regions</Section>
|
|
221
|
+
{regions.map((r) => (
|
|
222
|
+
<div key={r.name} className={`${ROW} mb-2`}>
|
|
223
|
+
<span className="flex-1 truncate font-medium">{r.label ?? r.name}</span>
|
|
224
|
+
<span className="text-fg-subtle">{(assembled?.regions[r.name] ?? []).length}</span>
|
|
225
|
+
</div>
|
|
226
|
+
))}
|
|
227
|
+
<Section>Status</Section>
|
|
228
|
+
<div className={ROW}>
|
|
229
|
+
<span className="flex-1 truncate font-medium">{page.title}</span>
|
|
230
|
+
<Pill status={page.status}>{page.status}</Pill>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div className="overflow-auto py-1.5">
|
|
235
|
+
{err ? <Banner>{err}</Banner> : null}
|
|
236
|
+
{msg ? <Banner ok>{msg}</Banner> : null}
|
|
237
|
+
{regions.map((r) => {
|
|
238
|
+
const blocks = assembled?.regions[r.name] ?? [];
|
|
239
|
+
const allowed = r.allowedTypes && r.allowedTypes.length ? r.allowedTypes : blockTypes.map((b) => b.slug);
|
|
240
|
+
return (
|
|
241
|
+
<div className="mb-6" key={r.name}>
|
|
242
|
+
<h3 className="m-0 mb-3 flex items-baseline gap-3 text-[22px] font-normal text-fg">
|
|
243
|
+
{r.label ?? r.name}
|
|
244
|
+
{r.allowedTypes ? <span className="text-[11px] font-normal text-fg-subtle">only: {r.allowedTypes.join(", ")}</span> : null}
|
|
245
|
+
</h3>
|
|
246
|
+
{blocks.map((b, i) => (
|
|
247
|
+
<div className={`mb-2.5 rounded-panel border bg-surface-card p-3.5 ${selected?.id === b.id ? "border-fg" : "border-border"}`} key={b.id} onClick={() => setSelected(b)}>
|
|
248
|
+
<div className="flex items-center gap-2.5">
|
|
249
|
+
<span className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg-muted">{b.block_type}</span>
|
|
250
|
+
{b.is_shared ? <span className="text-[11px] text-accent-strong">shared</span> : null}
|
|
251
|
+
<span className="flex-1 truncate text-fg-subtle">{summarize(b.fields)}</span>
|
|
252
|
+
<Button variant="ghost" size="sm" onPress={() => reorderMove(blocks, r.name, i, -1, reorder)}>↑</Button>
|
|
253
|
+
<Button variant="ghost" size="sm" onPress={() => reorderMove(blocks, r.name, i, 1, reorder)}>↓</Button>
|
|
254
|
+
<Button variant="ghost" size="sm" className="text-danger" onPress={() => removeBlock(b)}>✕</Button>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
|
259
|
+
{allowed.map((slug) => (
|
|
260
|
+
<Button key={slug} variant="secondary" size="sm" onPress={() => addBlock(r.name, slug)}>+ {btBySlug.get(slug)?.name ?? slug}</Button>
|
|
261
|
+
))}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
})}
|
|
266
|
+
{regions.length === 0 ? <p className="text-fg-subtle">This page's content type has no regions.</p> : null}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div className="overflow-auto rounded-panel border border-border bg-surface-card p-5">
|
|
270
|
+
{selected ? (
|
|
271
|
+
<BlockInspector api={api} block={selected} blockType={btBySlug.get(selected.block_type)} onClose={() => setSelected(null)} onSaved={reload} onError={setErr} />
|
|
272
|
+
) : (
|
|
273
|
+
<>
|
|
274
|
+
<div className="mb-3 flex gap-1">
|
|
275
|
+
{INSPECTOR_TABS.map((t) => (
|
|
276
|
+
<Button key={t} variant="ghost" size="sm" className={tab === t ? "bg-surface-muted text-fg" : "text-fg-muted"} onPress={() => onTab(t)}>{t}</Button>
|
|
277
|
+
))}
|
|
278
|
+
</div>
|
|
279
|
+
{tab === "settings" ? <Settings page={page} /> : null}
|
|
280
|
+
{tab === "seo" ? <SeoPanel api={api} page={page} onError={setErr} /> : null}
|
|
281
|
+
{tab === "workflow" ? <Workflow api={api} page={page} onChanged={(p) => { onChange(p); }} onError={setErr} /> : null}
|
|
282
|
+
{tab === "i18n" ? <I18n api={api} page={page} onError={setErr} /> : null}
|
|
283
|
+
{tab === "audit" ? <AuditLog api={api} pageId={page.id} onError={setErr} /> : null}
|
|
284
|
+
</>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function BlockInspector({ api, block, blockType, onClose, onSaved, onError }: { api: Api; block: RenderedBlock; blockType: BlockType | undefined; onClose: () => void; onSaved: () => void; onError: (s: string) => void }) {
|
|
292
|
+
const [fields, setFields] = useState<Record<string, unknown>>({});
|
|
293
|
+
const [busy, setBusy] = useState(false);
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
// Load RAW fields (media as ids) for editing.
|
|
296
|
+
api.call<{ fields?: Record<string, unknown> }>("getBlock", { blockId: block.block_id }).then((b) => setFields(b?.fields ?? {})).catch((e) => onError(errMsg(e)));
|
|
297
|
+
}, [api, block.block_id, onError]);
|
|
298
|
+
const schema: FieldDefinition[] = blockType?.fieldsSchema ?? [];
|
|
299
|
+
const save = async () => {
|
|
300
|
+
setBusy(true);
|
|
301
|
+
try {
|
|
302
|
+
await api.call("updateBlock", { blockId: block.block_id, fields });
|
|
303
|
+
onSaved();
|
|
304
|
+
} catch (e) {
|
|
305
|
+
onError(errMsg(e));
|
|
306
|
+
} finally {
|
|
307
|
+
setBusy(false);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
return (
|
|
311
|
+
<div>
|
|
312
|
+
<div className="flex items-center gap-2">
|
|
313
|
+
<span className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg-muted">{block.block_type}</span>
|
|
314
|
+
<span className="flex-1" />
|
|
315
|
+
<Button variant="ghost" size="sm" onPress={onClose}>done</Button>
|
|
316
|
+
</div>
|
|
317
|
+
{schema.length === 0 ? <p className="text-fg-subtle">This block type has no fields.</p> : <FieldForm schema={schema} value={fields} onChange={setFields} api={api} />}
|
|
318
|
+
<Button className="mt-3 w-full" onPress={save} isDisabled={busy}>Save block</Button>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function Settings({ page }: { page: Page }) {
|
|
324
|
+
// The core CMS API doesn't expose a general page-rename handler; keep this read-only +
|
|
325
|
+
// link the essentials. (A future `updatePage` handler would back editable title/slug.)
|
|
326
|
+
return (
|
|
327
|
+
<KV>
|
|
328
|
+
<span>Title</span>
|
|
329
|
+
<span>{page.title}</span>
|
|
330
|
+
<span>Slug</span>
|
|
331
|
+
<span>/{page.slug}</span>
|
|
332
|
+
<span>Locale</span>
|
|
333
|
+
<span>{page.locale}</span>
|
|
334
|
+
<span>Status</span>
|
|
335
|
+
<span>{page.status}</span>
|
|
336
|
+
</KV>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function SeoPanel({ api, page, onError }: { api: Api; page: Page; onError: (s: string) => void }) {
|
|
341
|
+
const [f, setF] = useState({ metaTitle: page.metaTitle ?? "", metaDescription: page.metaDescription ?? "", canonicalUrl: page.canonicalUrl ?? "", robots: page.robots ?? "", ogTitle: page.ogTitle ?? "", ogDescription: page.ogDescription ?? "" });
|
|
342
|
+
const [ok, setOk] = useState(false);
|
|
343
|
+
const save = async () => {
|
|
344
|
+
try {
|
|
345
|
+
await api.call("updatePageSeo", { pageId: page.id, ...f });
|
|
346
|
+
setOk(true);
|
|
347
|
+
setTimeout(() => setOk(false), 1500);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
onError(errMsg(e));
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const F = (k: keyof typeof f, label: string, area = false) =>
|
|
353
|
+
area ? (
|
|
354
|
+
<Textarea label={label} value={f[k]} onChange={(v) => setF({ ...f, [k]: v })} />
|
|
355
|
+
) : (
|
|
356
|
+
<Input label={label} value={f[k]} onChange={(v) => setF({ ...f, [k]: v })} />
|
|
357
|
+
);
|
|
358
|
+
return (
|
|
359
|
+
<div className="flex flex-col gap-4">
|
|
360
|
+
<Section>SEO</Section>
|
|
361
|
+
{ok ? <Banner ok>saved</Banner> : null}
|
|
362
|
+
{F("metaTitle", "Meta title")}
|
|
363
|
+
{F("metaDescription", "Meta description", true)}
|
|
364
|
+
{F("canonicalUrl", "Canonical URL")}
|
|
365
|
+
{F("robots", "Robots (e.g. noindex)")}
|
|
366
|
+
{F("ogTitle", "OG title")}
|
|
367
|
+
{F("ogDescription", "OG description", true)}
|
|
368
|
+
<Button className="w-full" onPress={save}>Save SEO</Button>
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function Workflow({ api, page, onChanged, onError }: { api: Api; page: Page; onChanged: (p: Page) => void; onError: (s: string) => void }) {
|
|
374
|
+
const act = async (name: string, input?: unknown) => {
|
|
375
|
+
try {
|
|
376
|
+
const r = await api.call<{ page?: Page }>(name, { pageId: page.id, ...(input as object) });
|
|
377
|
+
if (r?.page) onChanged(r.page);
|
|
378
|
+
} catch (e) {
|
|
379
|
+
onError(errMsg(e));
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
return (
|
|
383
|
+
<div>
|
|
384
|
+
<Section>Workflow</Section>
|
|
385
|
+
<div className="mb-3 flex items-center gap-2 text-[13px] text-fg-muted">
|
|
386
|
+
<span className="text-fg-subtle">Status</span>
|
|
387
|
+
<Pill status={page.status}>{page.status}</Pill>
|
|
388
|
+
</div>
|
|
389
|
+
<div className="flex flex-col gap-2">
|
|
390
|
+
<Button variant="secondary" onPress={() => act("submitForReview")}>Submit for review</Button>
|
|
391
|
+
<Button onPress={() => act("approve")}>Approve (publish)</Button>
|
|
392
|
+
<Button variant="secondary" onPress={() => act("reject")}>Reject</Button>
|
|
393
|
+
<Button variant="secondary" onPress={() => act("publishPage")}>Publish directly</Button>
|
|
394
|
+
<Button variant="ghost" onPress={() => act("unpublishPage")}>Unpublish</Button>
|
|
395
|
+
</div>
|
|
396
|
+
<p className="mt-2.5 text-fg-subtle">Submit is editor-gated; approve/publish are reviewer-gated.</p>
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function I18n({ api, page, onError }: { api: Api; page: Page; onError: (s: string) => void }) {
|
|
402
|
+
const [translations, setTranslations] = useState<{ id: string; locale: string; slug: string; status: string }[]>([]);
|
|
403
|
+
const [locale, setLocale] = useState("");
|
|
404
|
+
const refresh = useCallback(() => api.call<typeof translations>("listTranslations", { pageId: page.id }).then(setTranslations).catch((e) => onError(errMsg(e))), [api, page.id, onError]);
|
|
405
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
406
|
+
const create = async () => {
|
|
407
|
+
try {
|
|
408
|
+
await api.call("createTranslation", { pageId: page.id, locale });
|
|
409
|
+
setLocale("");
|
|
410
|
+
refresh();
|
|
411
|
+
} catch (e) {
|
|
412
|
+
onError(errMsg(e));
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
return (
|
|
416
|
+
<div>
|
|
417
|
+
<Section>Translations</Section>
|
|
418
|
+
<div className="flex flex-col gap-2">
|
|
419
|
+
{translations.map((t) => (
|
|
420
|
+
<div className={ROW} key={t.id}>
|
|
421
|
+
<span className="flex-1 truncate font-medium">{t.locale}</span>
|
|
422
|
+
<span className="text-fg-subtle">/{t.slug}</span>
|
|
423
|
+
<Pill status={t.status}>{t.status}</Pill>
|
|
424
|
+
</div>
|
|
425
|
+
))}
|
|
426
|
+
</div>
|
|
427
|
+
<div className="mt-2 flex items-end gap-1.5">
|
|
428
|
+
<Input label="" value={locale} onChange={setLocale} placeholder="locale (e.g. cs)" />
|
|
429
|
+
<Button variant="secondary" onPress={create} isDisabled={!locale}>add</Button>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function AuditLog({ api, pageId, onError }: { api: Api; pageId: string; onError: (s: string) => void }) {
|
|
436
|
+
const [rows, setRows] = useState<AuditEntry[]>([]);
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
api.listPageAudit(pageId).then(setRows).catch((e) => onError(errMsg(e)));
|
|
439
|
+
}, [api, pageId, onError]);
|
|
440
|
+
return (
|
|
441
|
+
<div>
|
|
442
|
+
<Section>Audit trail</Section>
|
|
443
|
+
<div className="flex flex-col gap-2">
|
|
444
|
+
{rows.map((a) => (
|
|
445
|
+
<div className={`${ROW} text-xs`} key={a.id}>
|
|
446
|
+
<span className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs text-fg-muted">{a.action}</span>
|
|
447
|
+
<span className="flex-1 truncate text-fg-subtle">{a.fromStatus} → {a.toStatus}</span>
|
|
448
|
+
<span className="text-fg-subtle">{a.actor ?? "system"}</span>
|
|
449
|
+
</div>
|
|
450
|
+
))}
|
|
451
|
+
{rows.length === 0 ? <p className="text-fg-subtle">No history yet.</p> : null}
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// --- media library (a top-level view: browse, upload, edit alt, delete) ---
|
|
458
|
+
const PAGE_SIZE = 60;
|
|
459
|
+
|
|
460
|
+
export function MediaLibrary({ api, onError }: { api: Api; onError: (s: string) => void }) {
|
|
461
|
+
const [media, setMedia] = useState<Media[]>([]);
|
|
462
|
+
const [offset, setOffset] = useState(0);
|
|
463
|
+
const [hasMore, setHasMore] = useState(false);
|
|
464
|
+
const [selected, setSelected] = useState<Media | null>(null);
|
|
465
|
+
const [busy, setBusy] = useState(false);
|
|
466
|
+
|
|
467
|
+
const load = useCallback(
|
|
468
|
+
(off: number) => {
|
|
469
|
+
api
|
|
470
|
+
.listMedia(PAGE_SIZE, off)
|
|
471
|
+
.then((rows) => {
|
|
472
|
+
setMedia((prev) => (off === 0 ? rows : [...prev, ...rows]));
|
|
473
|
+
setHasMore(rows.length === PAGE_SIZE);
|
|
474
|
+
setOffset(off + rows.length);
|
|
475
|
+
})
|
|
476
|
+
.catch((e) => onError(errMsg(e)));
|
|
477
|
+
},
|
|
478
|
+
[api, onError],
|
|
479
|
+
);
|
|
480
|
+
useEffect(() => { load(0); }, [load]);
|
|
481
|
+
|
|
482
|
+
const upload = async (files: FileList | null) => {
|
|
483
|
+
if (!files?.length) return;
|
|
484
|
+
setBusy(true);
|
|
485
|
+
onError("");
|
|
486
|
+
try {
|
|
487
|
+
for (const f of Array.from(files)) await api.uploadMedia(f);
|
|
488
|
+
load(0); // refresh from the top
|
|
489
|
+
} catch (e) {
|
|
490
|
+
onError(errMsg(e));
|
|
491
|
+
} finally {
|
|
492
|
+
setBusy(false);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<>
|
|
498
|
+
<Hero lead="Media" em={media.length === 0 ? "None yet" : media.length === 1 ? "1 file" : `${media.length}${hasMore ? "+" : ""} files`}>
|
|
499
|
+
<Cta text="Let's" em="upload something">
|
|
500
|
+
<label className={`inline-flex shrink-0 cursor-pointer items-center rounded-full bg-surface-inverted px-6 py-3 text-compact text-fg-inverted ${busy ? "pointer-events-none opacity-50" : ""}`}>
|
|
501
|
+
{busy ? "Uploading…" : "+ Upload"}
|
|
502
|
+
<input type="file" multiple hidden disabled={busy} onChange={(e) => { upload(e.target.files); e.target.value = ""; }} />
|
|
503
|
+
</label>
|
|
504
|
+
</Cta>
|
|
505
|
+
</Hero>
|
|
506
|
+
<div className={WRAP}>
|
|
507
|
+
{media.length === 0 ? (
|
|
508
|
+
<p className="text-fg-subtle">No media yet. Upload images to use them in blocks and SEO.</p>
|
|
509
|
+
) : (
|
|
510
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
|
|
511
|
+
{media.map((m) => (
|
|
512
|
+
<div key={m.id} className={`cursor-pointer overflow-hidden rounded-lg border bg-surface-card ${selected?.id === m.id ? "border-fg" : "border-border"}`} onClick={() => setSelected(m)}>
|
|
513
|
+
{isImage(m) ? <img className="block h-[130px] w-full object-cover" src={api.resolve(`/media/${m.file.key}`)} alt={m.alt ?? ""} /> : <div className="flex h-[130px] items-center justify-center bg-surface-muted font-mono text-xs text-fg-subtle">{ext(m)}</div>}
|
|
514
|
+
<div className="truncate px-2 py-1.5 text-[11px] text-fg-muted">{m.file.filename ?? m.id}</div>
|
|
515
|
+
</div>
|
|
516
|
+
))}
|
|
517
|
+
</div>
|
|
518
|
+
)}
|
|
519
|
+
{hasMore ? (
|
|
520
|
+
<div className="mt-3.5 text-center">
|
|
521
|
+
<Button variant="secondary" size="sm" onPress={() => load(offset)}>Load more</Button>
|
|
522
|
+
</div>
|
|
523
|
+
) : null}
|
|
524
|
+
{selected ? (
|
|
525
|
+
<MediaDetail
|
|
526
|
+
api={api}
|
|
527
|
+
media={selected}
|
|
528
|
+
onClose={() => setSelected(null)}
|
|
529
|
+
onSaved={(m) => { setSelected(m); setMedia((prev) => prev.map((x) => (x.id === m.id ? m : x))); }}
|
|
530
|
+
onDeleted={(id) => { setSelected(null); setMedia((prev) => prev.filter((x) => x.id !== id)); }}
|
|
531
|
+
onError={onError}
|
|
532
|
+
/>
|
|
533
|
+
) : null}
|
|
534
|
+
</div>
|
|
535
|
+
</>
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
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 }) {
|
|
540
|
+
const [alt, setAlt] = useState(media.alt ?? "");
|
|
541
|
+
const [busy, setBusy] = useState(false);
|
|
542
|
+
useEffect(() => setAlt(media.alt ?? ""), [media]);
|
|
543
|
+
const url = api.resolve(`/media/${media.file.key}`);
|
|
544
|
+
|
|
545
|
+
const save = async () => {
|
|
546
|
+
setBusy(true);
|
|
547
|
+
try {
|
|
548
|
+
onSaved(await api.updateMedia(media.id, alt || null));
|
|
549
|
+
} catch (e) {
|
|
550
|
+
onError(errMsg(e));
|
|
551
|
+
} finally {
|
|
552
|
+
setBusy(false);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
const del = async () => {
|
|
556
|
+
if (!confirm("Delete this file permanently? If a block or page still references it, that image will break — this cannot be undone.")) return;
|
|
557
|
+
setBusy(true);
|
|
558
|
+
try {
|
|
559
|
+
await api.deleteMedia(media.id);
|
|
560
|
+
onDeleted(media.id);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
onError(errMsg(e));
|
|
563
|
+
} finally {
|
|
564
|
+
setBusy(false);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
return (
|
|
569
|
+
<Modal onClose={onClose} wide>
|
|
570
|
+
<ModalTitle><Dim>Media</Dim> {media.file.filename ?? ""}</ModalTitle>
|
|
571
|
+
{isImage(media) ? (
|
|
572
|
+
<img className="mx-auto mb-3.5 block max-h-[340px] max-w-full rounded-lg bg-surface-muted object-contain" src={url} alt={media.alt ?? ""} />
|
|
573
|
+
) : (
|
|
574
|
+
<div className="mb-3.5 flex h-[200px] items-center justify-center rounded-lg bg-surface-muted font-mono text-fg-subtle">{ext(media)}</div>
|
|
575
|
+
)}
|
|
576
|
+
<div className="mb-4">
|
|
577
|
+
<Input label="Alt text (for accessibility & SEO)" value={alt} onChange={setAlt} placeholder="Describe the image…" />
|
|
578
|
+
</div>
|
|
579
|
+
<KV>
|
|
580
|
+
<span>Type</span>
|
|
581
|
+
<span>{media.file.contentType ?? "—"}</span>
|
|
582
|
+
<span>Size</span>
|
|
583
|
+
<span>{fmtBytes(media.file.size)}</span>
|
|
584
|
+
<span>Uploaded</span>
|
|
585
|
+
<span>{media.file.uploadedAt ? new Date(media.file.uploadedAt).toLocaleString() : (media.createdAt ?? "—")}</span>
|
|
586
|
+
<span>URL</span>
|
|
587
|
+
<span className="break-all">
|
|
588
|
+
<a className="underline underline-offset-2" href={url} target="_blank" rel="noreferrer">/media/{media.file.key}</a>
|
|
589
|
+
</span>
|
|
590
|
+
</KV>
|
|
591
|
+
<div className="mt-3.5 flex items-center gap-2">
|
|
592
|
+
<Button onPress={save} isDisabled={busy || alt === (media.alt ?? "")}>Save</Button>
|
|
593
|
+
<Button variant="secondary" size="sm" onPress={() => navigator.clipboard?.writeText(url)}>Copy URL</Button>
|
|
594
|
+
<span className="flex-1" />
|
|
595
|
+
<Button variant="ghost" className="text-danger" onPress={del} isDisabled={busy}>Delete</Button>
|
|
596
|
+
<Button variant="ghost" onPress={onClose}>Close</Button>
|
|
597
|
+
</div>
|
|
598
|
+
</Modal>
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// --- users management (admin) ------------------------------------------------
|
|
603
|
+
|
|
604
|
+
interface UserRow {
|
|
605
|
+
username: string;
|
|
606
|
+
email?: string | null;
|
|
607
|
+
roles?: string[] | string;
|
|
608
|
+
active?: boolean | number | null;
|
|
609
|
+
createdAt?: number | null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function rolesOf(u: UserRow): string[] {
|
|
613
|
+
const r = u.roles;
|
|
614
|
+
if (Array.isArray(r)) return r.filter((x): x is string => typeof x === "string");
|
|
615
|
+
if (typeof r === "string") { try { const j = JSON.parse(r); return Array.isArray(j) ? j : []; } catch { return []; } }
|
|
616
|
+
return [];
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function UsersView({ api, me, onError }: { api: Api; me: Me | null; onError: (s: string) => void }) {
|
|
620
|
+
const [users, setUsers] = useState<UserRow[]>([]);
|
|
621
|
+
const [inviting, setInviting] = useState(false);
|
|
622
|
+
const [busy, setBusy] = useState<string>("");
|
|
623
|
+
|
|
624
|
+
const refresh = useCallback(() => {
|
|
625
|
+
api.call<UserRow[]>("listUsers", { limit: 200 }).then(setUsers).catch((e) => onError(errMsg(e)));
|
|
626
|
+
}, [api, onError]);
|
|
627
|
+
useEffect(() => { refresh(); }, [refresh]);
|
|
628
|
+
|
|
629
|
+
const setRoles = async (u: UserRow, roles: string[]) => {
|
|
630
|
+
setBusy(u.username);
|
|
631
|
+
try { await api.call("setUserRoles", { username: u.username, roles }); refresh(); }
|
|
632
|
+
catch (e) { onError(errMsg(e)); }
|
|
633
|
+
finally { setBusy(""); }
|
|
634
|
+
};
|
|
635
|
+
const setActive = async (u: UserRow, active: boolean) => {
|
|
636
|
+
setBusy(u.username);
|
|
637
|
+
try { await api.call("setUserActive", { username: u.username, active }); refresh(); }
|
|
638
|
+
catch (e) { onError(errMsg(e)); }
|
|
639
|
+
finally { setBusy(""); }
|
|
640
|
+
};
|
|
641
|
+
const del = async (u: UserRow) => {
|
|
642
|
+
if (!confirm(`Delete user ${u.username}? This cannot be undone.`)) return;
|
|
643
|
+
setBusy(u.username);
|
|
644
|
+
try { await api.call("deleteUser", { username: u.username }); refresh(); }
|
|
645
|
+
catch (e) { onError(errMsg(e)); }
|
|
646
|
+
finally { setBusy(""); }
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<>
|
|
651
|
+
<Hero lead="Users" em={users.length === 0 ? "None yet" : users.length === 1 ? "1 account" : `${users.length} accounts`}>
|
|
652
|
+
<Cta text="Let's" em="invite someone">
|
|
653
|
+
<Button className="shrink-0" onPress={() => setInviting(true)}>+ Invite</Button>
|
|
654
|
+
</Cta>
|
|
655
|
+
</Hero>
|
|
656
|
+
<div className={WRAP}>
|
|
657
|
+
<div className="flex flex-col gap-2">
|
|
658
|
+
{users.map((u) => {
|
|
659
|
+
const roles = rolesOf(u);
|
|
660
|
+
const isMe = me?.userId === u.username;
|
|
661
|
+
const active = u.active === undefined || u.active === null ? true : Boolean(Number(u.active));
|
|
662
|
+
return (
|
|
663
|
+
<div className={`${ROW} flex-wrap items-start`} key={u.username}>
|
|
664
|
+
<div className="flex min-w-[200px] flex-1 flex-col gap-0.5">
|
|
665
|
+
<span className="font-semibold">{u.username}{isMe ? <span className="ml-1.5 font-normal text-fg-subtle">(you)</span> : null}</span>
|
|
666
|
+
{u.email && u.email !== u.username ? <span className="text-xs text-fg-subtle">{u.email}</span> : null}
|
|
667
|
+
{u.createdAt ? <span className="text-[11px] text-fg-subtle">joined {new Date(Number(u.createdAt)).toLocaleDateString()}</span> : null}
|
|
668
|
+
</div>
|
|
669
|
+
<RolesInput value={roles} disabled={busy === u.username} onSave={(next) => setRoles(u, next)} />
|
|
670
|
+
<Pill status={active ? "active" : "inactive"}>{active ? "active" : "inactive"}</Pill>
|
|
671
|
+
<Button variant="ghost" size="sm" isDisabled={busy === u.username || isMe} onPress={() => setActive(u, !active)}>{active ? "Deactivate" : "Activate"}</Button>
|
|
672
|
+
<Button variant="ghost" size="sm" className="text-danger" isDisabled={busy === u.username || isMe} onPress={() => del(u)}>Delete</Button>
|
|
673
|
+
</div>
|
|
674
|
+
);
|
|
675
|
+
})}
|
|
676
|
+
{users.length === 0 ? <p className="text-fg-subtle">No users yet. Invite someone to get started.</p> : null}
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
{inviting ? <InviteUser api={api} onClose={() => setInviting(false)} onInvited={() => { setInviting(false); refresh(); }} onError={onError} /> : null}
|
|
680
|
+
</>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function RolesInput({ value, disabled, onSave }: { value: string[]; disabled: boolean; onSave: (roles: string[]) => void }) {
|
|
685
|
+
const [text, setText] = useState(value.join(", "));
|
|
686
|
+
const [editing, setEditing] = useState(false);
|
|
687
|
+
useEffect(() => { setText(value.join(", ")); }, [value]);
|
|
688
|
+
const commit = () => {
|
|
689
|
+
setEditing(false);
|
|
690
|
+
const next = text.split(",").map((s) => s.trim()).filter(Boolean);
|
|
691
|
+
if (next.length === 0) { setText(value.join(", ")); return; }
|
|
692
|
+
if (next.length === value.length && next.every((r, i) => r === value[i])) return;
|
|
693
|
+
onSave(next);
|
|
694
|
+
};
|
|
695
|
+
if (editing) {
|
|
696
|
+
return (
|
|
697
|
+
<input
|
|
698
|
+
autoFocus
|
|
699
|
+
className="h-10 w-[220px] rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none focus:border-brand-green"
|
|
700
|
+
value={text}
|
|
701
|
+
disabled={disabled}
|
|
702
|
+
onChange={(e) => setText(e.target.value)}
|
|
703
|
+
onBlur={commit}
|
|
704
|
+
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setText(value.join(", ")); setEditing(false); } }}
|
|
705
|
+
/>
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return (
|
|
709
|
+
<span className="flex cursor-pointer flex-wrap gap-1" onClick={() => setEditing(true)} title="Click to edit">
|
|
710
|
+
{value.length === 0 ? <Pill>no roles</Pill> : value.map((r) => <Pill key={r} status={r === "admin" ? "published" : undefined}>{r}</Pill>)}
|
|
711
|
+
</span>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function InviteUser({ api, onClose, onInvited, onError }: { api: Api; onClose: () => void; onInvited: () => void; onError: (s: string) => void }) {
|
|
716
|
+
const [email, setEmail] = useState("");
|
|
717
|
+
const [roles, setRoles] = useState("editor");
|
|
718
|
+
const [busy, setBusy] = useState(false);
|
|
719
|
+
const invite = async () => {
|
|
720
|
+
setBusy(true);
|
|
721
|
+
try {
|
|
722
|
+
const rs = roles.split(",").map((s) => s.trim()).filter(Boolean);
|
|
723
|
+
await api.call("inviteUser", { email, roles: rs });
|
|
724
|
+
onInvited();
|
|
725
|
+
} catch (e) {
|
|
726
|
+
onError(errMsg(e));
|
|
727
|
+
} finally {
|
|
728
|
+
setBusy(false);
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
return (
|
|
732
|
+
<Modal onClose={onClose}>
|
|
733
|
+
<ModalTitle>Invite an <Dim>editor</Dim> or teammate</ModalTitle>
|
|
734
|
+
<p className="-mt-2 mb-4 text-fg-subtle">They'll get a one-time magic link that logs them in and creates their account.</p>
|
|
735
|
+
<div className="flex flex-col gap-4">
|
|
736
|
+
<Input label="Email" type="email" autoFocus value={email} onChange={setEmail} placeholder="them@example.com" />
|
|
737
|
+
<Input label="Roles (comma-separated — e.g. editor, reviewer, admin)" value={roles} onChange={setRoles} placeholder="editor" />
|
|
738
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
739
|
+
<Button variant="ghost" onPress={onClose}>Cancel</Button>
|
|
740
|
+
<Button onPress={invite} isDisabled={busy || !email}>{busy ? "Sending…" : "Send invite"}</Button>
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
</Modal>
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// --- settings ----------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
export function SettingsView({ api, cfg, me, onSignOut, onError }: { api: Api; cfg: Config; me: Me | null; onSignOut: () => void; onError: (s: string) => void }) {
|
|
750
|
+
return (
|
|
751
|
+
<>
|
|
752
|
+
<Hero lead="Settings" em={me?.userId ?? "your account"} />
|
|
753
|
+
<div className={`${WRAP} grid grid-cols-2 gap-5 max-[820px]:grid-cols-1`}>
|
|
754
|
+
<MyAccountCard api={api} me={me} onError={onError} onSignOut={onSignOut} />
|
|
755
|
+
<AboutCard cfg={cfg} me={me} />
|
|
756
|
+
</div>
|
|
757
|
+
</>
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function MyAccountCard({ api, me, onError, onSignOut }: { api: Api; me: Me | null; onError: (s: string) => void; onSignOut: () => void }) {
|
|
762
|
+
const [email, setEmail] = useState("");
|
|
763
|
+
const [pwCurrent, setPwCurrent] = useState("");
|
|
764
|
+
const [pwNew, setPwNew] = useState("");
|
|
765
|
+
const [msg, setMsg] = useState("");
|
|
766
|
+
const [busy, setBusy] = useState(false);
|
|
767
|
+
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
|
|
768
|
+
|
|
769
|
+
const saveEmail = async () => {
|
|
770
|
+
setBusy(true);
|
|
771
|
+
try { await api.call("changeEmail", { email }); setEmail(""); flash("Contact email updated"); }
|
|
772
|
+
catch (e) { onError(errMsg(e)); }
|
|
773
|
+
finally { setBusy(false); }
|
|
774
|
+
};
|
|
775
|
+
const savePassword = async () => {
|
|
776
|
+
setBusy(true);
|
|
777
|
+
try { await api.call("changePassword", { currentPassword: pwCurrent, newPassword: pwNew }); setPwCurrent(""); setPwNew(""); flash("Password updated"); }
|
|
778
|
+
catch (e) { onError(errMsg(e)); }
|
|
779
|
+
finally { setBusy(false); }
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
return (
|
|
783
|
+
<Card>
|
|
784
|
+
<Section>My account</Section>
|
|
785
|
+
{msg ? <Banner ok>{msg}</Banner> : null}
|
|
786
|
+
<KV className="mb-4">
|
|
787
|
+
<span>Username</span><span>{me?.userId ?? "—"}</span>
|
|
788
|
+
<span>Roles</span><span>{(me?.roles ?? []).join(", ") || "—"}</span>
|
|
789
|
+
</KV>
|
|
790
|
+
<div className="flex flex-col gap-4">
|
|
791
|
+
<Input label="Change contact email" type="email" value={email} onChange={setEmail} placeholder="you@example.com" />
|
|
792
|
+
<Button className="w-full" onPress={saveEmail} isDisabled={busy || !email}>Save email</Button>
|
|
793
|
+
<Input label="Current password" type="password" value={pwCurrent} onChange={setPwCurrent} autoComplete="current-password" />
|
|
794
|
+
<Input label="New password (at least 8 characters)" type="password" value={pwNew} onChange={setPwNew} autoComplete="new-password" />
|
|
795
|
+
<Button className="w-full" onPress={savePassword} isDisabled={busy || pwNew.length < 8 || pwCurrent.length === 0}>Change password</Button>
|
|
796
|
+
<Button variant="ghost" className="mt-2 w-full text-danger" onPress={onSignOut}>Sign out</Button>
|
|
797
|
+
</div>
|
|
798
|
+
</Card>
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function AboutCard({ cfg, me }: { cfg: Config; me: Me | null }) {
|
|
803
|
+
return (
|
|
804
|
+
<Card>
|
|
805
|
+
<Section>About</Section>
|
|
806
|
+
<KV>
|
|
807
|
+
<span>Tenant</span><span>{cfg.tenant || "main"}</span>
|
|
808
|
+
<span>API</span><span className="break-all">{cfg.baseUrl}</span>
|
|
809
|
+
<span>Signed in as</span><span>{me?.userId ?? "—"}</span>
|
|
810
|
+
<span>Editor</span><span>pramen · cms-editor</span>
|
|
811
|
+
</KV>
|
|
812
|
+
</Card>
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// --- helpers ---
|
|
817
|
+
function isImage(m: Media): boolean {
|
|
818
|
+
return (m.file.contentType ?? "").startsWith("image/");
|
|
819
|
+
}
|
|
820
|
+
function ext(m: Media): string {
|
|
821
|
+
const fromType = (m.file.contentType ?? "").split("/")[1];
|
|
822
|
+
const fromName = m.file.filename?.split(".").pop();
|
|
823
|
+
return (fromName ?? fromType ?? "file").slice(0, 5).toUpperCase();
|
|
824
|
+
}
|
|
825
|
+
function fmtBytes(n?: number): string {
|
|
826
|
+
if (!n || n <= 0) return "—";
|
|
827
|
+
const u = ["B", "KB", "MB", "GB"];
|
|
828
|
+
let i = 0;
|
|
829
|
+
let v = n;
|
|
830
|
+
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
|
831
|
+
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function errMsg(e: unknown): string {
|
|
835
|
+
if (e instanceof ApiError) return e.message;
|
|
836
|
+
return e instanceof Error ? e.message : String(e);
|
|
837
|
+
}
|
|
838
|
+
function slugify(s: string): string {
|
|
839
|
+
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
840
|
+
}
|
|
841
|
+
function summarize(fields: Record<string, unknown>): string {
|
|
842
|
+
const first = Object.values(fields).find((v) => typeof v === "string" && v);
|
|
843
|
+
return typeof first === "string" ? (first.length > 48 ? first.slice(0, 48) + "…" : first) : "";
|
|
844
|
+
}
|
|
845
|
+
function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: number, reorder: (region: string, order: string[]) => void) {
|
|
846
|
+
const j = i + d;
|
|
847
|
+
if (j < 0 || j >= blocks.length) return;
|
|
848
|
+
const ids = blocks.map((b) => b.id);
|
|
849
|
+
[ids[i], ids[j]] = [ids[j], ids[i]];
|
|
850
|
+
reorder(region, ids);
|
|
851
|
+
}
|