@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
package/src/fields.tsx
CHANGED
|
@@ -1,103 +1,99 @@
|
|
|
1
1
|
// Schema-driven field forms: one input per FieldDefinition type, recursively composed for
|
|
2
2
|
// group/repeater. Media fields open a picker (upload + choose from the library).
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { Button, Input, Textarea } from "@podoba/react";
|
|
5
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
5
6
|
import type { Api } from "./api";
|
|
6
7
|
import type { FieldDefinition, Media } from "./types";
|
|
7
8
|
|
|
9
|
+
// Tokenized bare control (podoba's filled-field skin) for the native inputs that
|
|
10
|
+
// don't map cleanly onto a podoba primitive (number/date/select/file).
|
|
11
|
+
const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
|
|
12
|
+
|
|
13
|
+
function FieldShell({ label, children }: { label: ReactNode; children: ReactNode }) {
|
|
14
|
+
return (
|
|
15
|
+
<label className="flex w-full flex-col gap-2">
|
|
16
|
+
<span className="text-sm font-medium text-fg">{label}</span>
|
|
17
|
+
{children}
|
|
18
|
+
</label>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export function FieldForm({ schema, value, onChange, api }: { schema: FieldDefinition[]; value: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void; api: Api }) {
|
|
9
23
|
const set = (name: string, v: unknown) => onChange({ ...value, [name]: v });
|
|
10
24
|
return (
|
|
11
|
-
|
|
25
|
+
<div className="flex flex-col gap-4">
|
|
12
26
|
{schema.map((def) => (
|
|
13
27
|
<FieldInput key={def.name} def={def} value={value[def.name]} onChange={(v) => set(def.name, v)} api={api} />
|
|
14
28
|
))}
|
|
15
|
-
|
|
29
|
+
</div>
|
|
16
30
|
);
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
function FieldInput({ def, value, onChange, api }: { def: FieldDefinition; value: unknown; onChange: (v: unknown) => void; api: Api }) {
|
|
20
|
-
const label = (
|
|
21
|
-
|
|
22
|
-
{def.label ?? def.name} {def.required ? <span className="
|
|
23
|
-
|
|
34
|
+
const label: ReactNode = (
|
|
35
|
+
<>
|
|
36
|
+
{def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
|
|
37
|
+
</>
|
|
24
38
|
);
|
|
25
39
|
switch (def.type) {
|
|
26
40
|
case "text":
|
|
27
41
|
case "url":
|
|
28
|
-
return (
|
|
29
|
-
<label className="field">
|
|
30
|
-
{label}
|
|
31
|
-
<input value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value)} placeholder={def.type === "url" ? "https://…" : ""} />
|
|
32
|
-
</label>
|
|
33
|
-
);
|
|
42
|
+
return <Input label={label} value={(value as string) ?? ""} onChange={onChange} placeholder={def.type === "url" ? "https://…" : ""} />;
|
|
34
43
|
case "textarea":
|
|
35
|
-
return (
|
|
36
|
-
<label className="field">
|
|
37
|
-
{label}
|
|
38
|
-
<textarea value={typeof value === "string" ? value : value == null ? "" : JSON.stringify(value)} onChange={(e) => onChange(e.target.value)} />
|
|
39
|
-
</label>
|
|
40
|
-
);
|
|
44
|
+
return <Textarea label={label} value={typeof value === "string" ? value : value == null ? "" : JSON.stringify(value)} onChange={onChange} />;
|
|
41
45
|
case "richtext":
|
|
42
46
|
// A rich-text value is an HTML string (round-trips with the site's set:html
|
|
43
47
|
// renderers). A legacy object value isn't editable here — fall back to raw text.
|
|
44
48
|
return (
|
|
45
|
-
<
|
|
46
|
-
{label}
|
|
49
|
+
<FieldShell label={label}>
|
|
47
50
|
<RichText value={typeof value === "string" ? value : ""} onChange={onChange as (v: string) => void} />
|
|
48
|
-
</
|
|
51
|
+
</FieldShell>
|
|
49
52
|
);
|
|
50
53
|
case "number":
|
|
51
54
|
return (
|
|
52
|
-
<label
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
</label>
|
|
55
|
+
<FieldShell label={label}>
|
|
56
|
+
<input className={CONTROL} type="number" value={value == null ? "" : String(value)} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} />
|
|
57
|
+
</FieldShell>
|
|
56
58
|
);
|
|
57
59
|
case "date":
|
|
58
60
|
case "datetime":
|
|
59
61
|
return (
|
|
60
|
-
<label
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
</label>
|
|
62
|
+
<FieldShell label={label}>
|
|
63
|
+
<input className={CONTROL} type={def.type === "date" ? "date" : "datetime-local"} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)} />
|
|
64
|
+
</FieldShell>
|
|
64
65
|
);
|
|
65
66
|
case "boolean":
|
|
66
67
|
return (
|
|
67
|
-
<label className="
|
|
68
|
+
<label className="flex items-center gap-2">
|
|
68
69
|
<input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
|
|
69
|
-
<span>{def.label ?? def.name}</span>
|
|
70
|
+
<span className="text-sm text-fg">{def.label ?? def.name}</span>
|
|
70
71
|
</label>
|
|
71
72
|
);
|
|
72
73
|
case "select":
|
|
73
74
|
return (
|
|
74
|
-
<label
|
|
75
|
-
{
|
|
76
|
-
<select value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)}>
|
|
75
|
+
<FieldShell label={label}>
|
|
76
|
+
<select className={CONTROL} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)}>
|
|
77
77
|
<option value="">—</option>
|
|
78
78
|
{(def.options ?? []).map((o) => (
|
|
79
|
-
<option key={o} value={o}>
|
|
80
|
-
{o}
|
|
81
|
-
</option>
|
|
79
|
+
<option key={o} value={o}>{o}</option>
|
|
82
80
|
))}
|
|
83
81
|
</select>
|
|
84
|
-
</
|
|
82
|
+
</FieldShell>
|
|
85
83
|
);
|
|
86
84
|
case "media":
|
|
87
85
|
return (
|
|
88
|
-
<label
|
|
89
|
-
{label}
|
|
86
|
+
<FieldShell label={label}>
|
|
90
87
|
<MediaField value={value as string | null} onChange={onChange} api={api} />
|
|
91
|
-
</
|
|
88
|
+
</FieldShell>
|
|
92
89
|
);
|
|
93
90
|
case "group":
|
|
94
91
|
return (
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
<div className="group">
|
|
92
|
+
<FieldShell label={label}>
|
|
93
|
+
<div className="rounded-lg border border-border bg-surface-muted p-3.5">
|
|
98
94
|
<FieldForm schema={def.fields ?? []} value={(value as Record<string, unknown>) ?? {}} onChange={onChange as (v: Record<string, unknown>) => void} api={api} />
|
|
99
95
|
</div>
|
|
100
|
-
</
|
|
96
|
+
</FieldShell>
|
|
101
97
|
);
|
|
102
98
|
case "repeater":
|
|
103
99
|
return <Repeater def={def} value={(value as Record<string, unknown>[]) ?? []} onChange={onChange as (v: unknown[]) => void} api={api} label={label} />;
|
|
@@ -184,21 +180,30 @@ export function RichText({ value, onChange }: { value: string; onChange: (v: str
|
|
|
184
180
|
};
|
|
185
181
|
|
|
186
182
|
return (
|
|
187
|
-
<div className="
|
|
188
|
-
<div className="
|
|
183
|
+
<div className="overflow-hidden rounded-lg border border-border bg-surface-card">
|
|
184
|
+
<div className="flex flex-wrap gap-0.5 border-b border-border bg-surface-muted px-2 py-1.5">
|
|
189
185
|
{RT_TOOLS.map((t) => (
|
|
190
186
|
// preventDefault on mousedown keeps the editor's selection while the button is clicked.
|
|
191
|
-
<button key={t.label} type="button" className="
|
|
187
|
+
<button key={t.label} type="button" className="rounded border-0 bg-transparent px-2.5 py-0.5 text-xs text-fg-muted hover:bg-surface-card hover:text-fg" title={t.title} onMouseDown={(e) => e.preventDefault()} onClick={() => t.run(exec)}>
|
|
192
188
|
{t.label}
|
|
193
189
|
</button>
|
|
194
190
|
))}
|
|
195
191
|
</div>
|
|
196
|
-
<div
|
|
192
|
+
<div
|
|
193
|
+
ref={ref}
|
|
194
|
+
className="prose prose-sm min-h-[180px] max-w-none px-4 py-3.5 text-sm leading-relaxed text-fg outline-none empty:before:text-fg-subtle empty:before:content-[attr(data-placeholder)]"
|
|
195
|
+
contentEditable
|
|
196
|
+
suppressContentEditableWarning
|
|
197
|
+
data-placeholder="Write…"
|
|
198
|
+
onInput={emit}
|
|
199
|
+
onBlur={emit}
|
|
200
|
+
onPaste={onPaste}
|
|
201
|
+
/>
|
|
197
202
|
</div>
|
|
198
203
|
);
|
|
199
204
|
}
|
|
200
205
|
|
|
201
|
-
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label:
|
|
206
|
+
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: Record<string, unknown>[]; onChange: (v: unknown[]) => void; api: Api; label: ReactNode }) {
|
|
202
207
|
const items = Array.isArray(value) ? value : [];
|
|
203
208
|
const upd = (i: number, v: Record<string, unknown>) => onChange(items.map((it, j) => (j === i ? v : it)));
|
|
204
209
|
const add = () => onChange([...items, {}]);
|
|
@@ -211,27 +216,21 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
211
216
|
onChange(next);
|
|
212
217
|
};
|
|
213
218
|
return (
|
|
214
|
-
<div className="
|
|
215
|
-
{label}
|
|
219
|
+
<div className="flex flex-col gap-2">
|
|
220
|
+
<span className="text-sm font-medium text-fg">{label}</span>
|
|
216
221
|
{items.map((it, i) => (
|
|
217
|
-
<div className="
|
|
218
|
-
<div className="
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
<button type="button" className="ghost sm" onClick={() => move(i, 1)}>
|
|
223
|
-
↓
|
|
224
|
-
</button>
|
|
225
|
-
<button type="button" className="ghost sm danger" onClick={() => del(i)}>
|
|
226
|
-
✕
|
|
227
|
-
</button>
|
|
222
|
+
<div className="rounded-lg border border-border bg-surface-muted p-3.5" key={i}>
|
|
223
|
+
<div className="mb-2 flex justify-end gap-1">
|
|
224
|
+
<Button variant="ghost" size="sm" onPress={() => move(i, -1)}>↑</Button>
|
|
225
|
+
<Button variant="ghost" size="sm" onPress={() => move(i, 1)}>↓</Button>
|
|
226
|
+
<Button variant="ghost" size="sm" className="text-danger" onPress={() => del(i)}>✕</Button>
|
|
228
227
|
</div>
|
|
229
228
|
<FieldForm schema={def.fields ?? []} value={it} onChange={(v) => upd(i, v)} api={api} />
|
|
230
229
|
</div>
|
|
231
230
|
))}
|
|
232
|
-
<
|
|
231
|
+
<Button variant="secondary" size="sm" className="self-start" onPress={add} isDisabled={def.max != null && items.length >= def.max}>
|
|
233
232
|
+ add {def.label ?? def.name}
|
|
234
|
-
</
|
|
233
|
+
</Button>
|
|
235
234
|
</div>
|
|
236
235
|
);
|
|
237
236
|
}
|
|
@@ -245,16 +244,12 @@ function MediaField({ value, onChange, api }: { value: string | null; onChange:
|
|
|
245
244
|
}, [value, api]);
|
|
246
245
|
return (
|
|
247
246
|
<div>
|
|
248
|
-
<div className="
|
|
249
|
-
{media ? <img src={api.resolve(`/media/${media.file.key}`)} alt=""
|
|
250
|
-
<span className="
|
|
251
|
-
<
|
|
252
|
-
pick
|
|
253
|
-
</button>
|
|
247
|
+
<div className="flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5">
|
|
248
|
+
{media ? <img className="h-10 w-10 rounded object-cover" src={api.resolve(`/media/${media.file.key}`)} alt="" /> : <span className="text-fg-subtle">no media</span>}
|
|
249
|
+
<span className="flex-1 truncate text-fg-subtle">{media?.file.filename ?? value ?? ""}</span>
|
|
250
|
+
<Button variant="secondary" size="sm" onPress={() => setOpen(true)}>pick</Button>
|
|
254
251
|
{value ? (
|
|
255
|
-
<
|
|
256
|
-
clear
|
|
257
|
-
</button>
|
|
252
|
+
<Button variant="ghost" size="sm" className="text-danger" onPress={() => onChange(null)}>clear</Button>
|
|
258
253
|
) : null}
|
|
259
254
|
</div>
|
|
260
255
|
{open ? (
|
|
@@ -293,28 +288,24 @@ export function MediaPicker({ api, onClose, onPick }: { api: Api; onClose: () =>
|
|
|
293
288
|
}
|
|
294
289
|
};
|
|
295
290
|
return (
|
|
296
|
-
<div className="
|
|
297
|
-
<div className="
|
|
298
|
-
<h2>
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
<span className="lbl">Upload a new file</span>
|
|
304
|
-
<input type="file" disabled={busy} onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
|
|
291
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(20,15,5,0.28)] p-6" onClick={onClose}>
|
|
292
|
+
<div className="max-h-[86vh] w-full max-w-[680px] overflow-auto rounded-panel border border-border bg-surface-card px-9 py-8 shadow-[0_24px_60px_rgba(30,20,10,0.12)]" onClick={(e) => e.stopPropagation()}>
|
|
293
|
+
<h2 className="mb-5 text-[28px] font-normal text-fg">Choose <span className="text-fg-subtle">a file</span> from the library</h2>
|
|
294
|
+
{err ? <div className="my-2 rounded-lg border border-danger bg-surface-card px-3.5 py-2.5 text-[13px] text-danger">{err}</div> : null}
|
|
295
|
+
<label className="mb-4 flex w-full flex-col gap-2">
|
|
296
|
+
<span className="text-sm font-medium text-fg">Upload a new file</span>
|
|
297
|
+
<input type="file" className="text-sm text-fg-muted" disabled={busy} onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
|
|
305
298
|
</label>
|
|
306
|
-
<div className="
|
|
299
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2.5">
|
|
307
300
|
{media.map((m) => (
|
|
308
|
-
<div key={m.id} className="
|
|
309
|
-
{(m.file.contentType ?? "").startsWith("image/") ? <img src={api.resolve(`/media/${m.file.key}`)} alt="" /> : <div
|
|
310
|
-
<div className="
|
|
301
|
+
<div key={m.id} className="cursor-pointer overflow-hidden rounded-lg border border-border bg-surface-card" onClick={() => onPick(m.id)}>
|
|
302
|
+
{(m.file.contentType ?? "").startsWith("image/") ? <img className="block h-[130px] w-full object-cover" src={api.resolve(`/media/${m.file.key}`)} alt="" /> : <div className="h-[130px] bg-surface-muted" />}
|
|
303
|
+
<div className="truncate px-2 py-1.5 text-[11px] text-fg-muted">{m.file.filename ?? m.id}</div>
|
|
311
304
|
</div>
|
|
312
305
|
))}
|
|
313
306
|
</div>
|
|
314
|
-
<div
|
|
315
|
-
<
|
|
316
|
-
close
|
|
317
|
-
</button>
|
|
307
|
+
<div className="mt-3 text-right">
|
|
308
|
+
<Button variant="ghost" onPress={onClose}>close</Button>
|
|
318
309
|
</div>
|
|
319
310
|
</div>
|
|
320
311
|
</div>
|
package/src/main.tsx
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
import { BuzolaProvider } from "@buzola/router";
|
|
1
2
|
import { StrictMode } from "react";
|
|
2
3
|
import { createRoot } from "react-dom/client";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { pageRegistry, routes } from "virtual:buzola/routes";
|
|
5
|
+
import { AppProvider } from "./app-context";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
document.head.appendChild(style);
|
|
7
|
+
// Styling is podoba: @podoba/tokens/variables.css + the compiled Tailwind (podoba
|
|
8
|
+
// preset) are <link>ed by index.html (see scripts/build.ts). No more inline CSS.
|
|
9
9
|
|
|
10
10
|
const el = document.getElementById("app");
|
|
11
|
-
if (el)
|
|
11
|
+
if (el)
|
|
12
|
+
createRoot(el).render(
|
|
13
|
+
<StrictMode>
|
|
14
|
+
<AppProvider>
|
|
15
|
+
<BuzolaProvider routes={routes} pageRegistry={pageRegistry} />
|
|
16
|
+
</AppProvider>
|
|
17
|
+
</StrictMode>,
|
|
18
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Fallback for an unmatched path.
|
|
2
|
+
|
|
3
|
+
import { createPage, useNavigate } from "@buzola/router";
|
|
4
|
+
import { Button } from "@podoba/react";
|
|
5
|
+
|
|
6
|
+
export default createPage().render(function NotFound() {
|
|
7
|
+
const navigate = useNavigate();
|
|
8
|
+
return (
|
|
9
|
+
<div className="mx-auto max-w-[1200px] px-7 pt-8">
|
|
10
|
+
<h1 className="m-0 text-[56px] font-normal leading-[1.05] tracking-[-0.01em] max-[820px]:text-[40px]">
|
|
11
|
+
<span className="block text-fg-subtle">Not found</span>
|
|
12
|
+
<span className="block text-fg">Nothing lives here</span>
|
|
13
|
+
</h1>
|
|
14
|
+
<div className="mt-4 flex items-center gap-2">
|
|
15
|
+
<p className="text-fg-subtle">That page doesn't exist.</p>
|
|
16
|
+
<Button variant="ghost" size="sm" onPress={() => navigate("home")}>← back to pages</Button>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Root layout: the persistent chrome (top bar + tab nav + global error banner) wrapped
|
|
2
|
+
// around every route via <Outlet />. Tab highlighting is derived from the current path,
|
|
3
|
+
// so a deep link or refresh lands with the right tab lit.
|
|
4
|
+
|
|
5
|
+
import { Outlet, useNavigate, useRoute } from "@buzola/router";
|
|
6
|
+
import { Button } from "@podoba/react";
|
|
7
|
+
import { useApp } from "../app-context";
|
|
8
|
+
|
|
9
|
+
export default function RootLayout() {
|
|
10
|
+
const { cfg, isAdmin, error, reconfigure } = useApp();
|
|
11
|
+
const navigate = useNavigate();
|
|
12
|
+
const { pathname } = useRoute();
|
|
13
|
+
|
|
14
|
+
// "Pages" stays lit while editing a page (/pages/:id) too.
|
|
15
|
+
const active = pathname.startsWith("/pages") || pathname === "/" ? "pages"
|
|
16
|
+
: pathname.startsWith("/media") ? "media"
|
|
17
|
+
: pathname.startsWith("/users") ? "users"
|
|
18
|
+
: pathname.startsWith("/settings") ? "settings"
|
|
19
|
+
: "";
|
|
20
|
+
|
|
21
|
+
const tabCls = (key: string) =>
|
|
22
|
+
active === key ? "bg-surface-muted text-fg" : "text-fg-muted";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<div className="sticky top-0 z-10 flex items-center gap-4 bg-surface px-7 py-4">
|
|
27
|
+
<span className="text-[15px] font-bold tracking-[0.01em] text-fg">
|
|
28
|
+
pramen <span className="font-normal text-fg-subtle">· cms</span>
|
|
29
|
+
</span>
|
|
30
|
+
<span className="flex-1" />
|
|
31
|
+
<nav className="flex items-center gap-0.5">
|
|
32
|
+
<Button variant="ghost" size="sm" className={tabCls("pages")} onPress={() => navigate("home")}>Pages</Button>
|
|
33
|
+
<Button variant="ghost" size="sm" className={tabCls("media")} onPress={() => navigate("media")}>Media</Button>
|
|
34
|
+
{isAdmin ? (
|
|
35
|
+
<Button variant="ghost" size="sm" className={tabCls("users")} onPress={() => navigate("users")}>Users</Button>
|
|
36
|
+
) : null}
|
|
37
|
+
<Button variant="ghost" size="sm" className={tabCls("settings")} onPress={() => navigate("settings")}>Settings</Button>
|
|
38
|
+
</nav>
|
|
39
|
+
<span className="ml-3 text-fg-subtle">{cfg.tenant}</span>
|
|
40
|
+
<Button variant="ghost" size="sm" onPress={reconfigure}>sign out</Button>
|
|
41
|
+
</div>
|
|
42
|
+
{error ? (
|
|
43
|
+
<div className="mx-7 mt-2 rounded-lg border border-danger bg-surface-card px-4 py-2.5 text-sm text-danger">{error}</div>
|
|
44
|
+
) : null}
|
|
45
|
+
<Outlet />
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Home route (`/`): the page list. Opening a page navigates to /pages/:pageId.
|
|
2
|
+
|
|
3
|
+
import { createPage, useNavigate } from "@buzola/router";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { useApp } from "../app-context";
|
|
6
|
+
import { PageList, errMsg } from "../components";
|
|
7
|
+
import type { BlockType, Page } from "../types";
|
|
8
|
+
|
|
9
|
+
export default createPage()
|
|
10
|
+
.route("/")
|
|
11
|
+
.render(function Home() {
|
|
12
|
+
const { api, setError } = useApp();
|
|
13
|
+
const navigate = useNavigate();
|
|
14
|
+
const [pages, setPages] = useState<Page[]>([]);
|
|
15
|
+
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
16
|
+
|
|
17
|
+
const refreshPages = () => api.listPages().then(setPages).catch((e) => setError(errMsg(e)));
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
refreshPages();
|
|
20
|
+
api.listBlockTypes().then(setBlockTypes).catch((e) => setError(errMsg(e)));
|
|
21
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
22
|
+
}, [api]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<PageList
|
|
26
|
+
api={api}
|
|
27
|
+
pages={pages}
|
|
28
|
+
blockTypes={blockTypes}
|
|
29
|
+
onOpen={(p) => navigate("page", { params: { pageId: p.id } })}
|
|
30
|
+
onCreated={refreshPages}
|
|
31
|
+
onError={setError}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Media library route (`/media`).
|
|
2
|
+
|
|
3
|
+
import { createPage } from "@buzola/router";
|
|
4
|
+
import { useApp } from "../app-context";
|
|
5
|
+
import { MediaLibrary } from "../components";
|
|
6
|
+
|
|
7
|
+
export default createPage()
|
|
8
|
+
.route("/media")
|
|
9
|
+
.render(function Media() {
|
|
10
|
+
const { api, setError } = useApp();
|
|
11
|
+
return <MediaLibrary api={api} onError={setError} />;
|
|
12
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Page editor route (`/pages/:pageId`). The inspector tab is carried in the URL as
|
|
2
|
+
// `?tab=` so a specific panel is deep-linkable and survives refresh; block selection
|
|
3
|
+
// stays local (it's a transient in-canvas overlay).
|
|
4
|
+
|
|
5
|
+
import { createPage, useNavigate } from "@buzola/router";
|
|
6
|
+
import { Button } from "@podoba/react";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { useApp } from "../app-context";
|
|
9
|
+
import { INSPECTOR_TABS, PageEditor, errMsg, type InspectorTab } from "../components";
|
|
10
|
+
import type { BlockType, Page } from "../types";
|
|
11
|
+
|
|
12
|
+
export default createPage()
|
|
13
|
+
.params({ pageId: "string", tab: "?string" })
|
|
14
|
+
.route("/pages/:pageId")
|
|
15
|
+
.render(function PageEditorRoute({ params }) {
|
|
16
|
+
const { api, setError } = useApp();
|
|
17
|
+
const navigate = useNavigate();
|
|
18
|
+
const [page, setPage] = useState<Page | null>(null);
|
|
19
|
+
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
20
|
+
const [missing, setMissing] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let live = true;
|
|
24
|
+
setMissing(false);
|
|
25
|
+
// No get-page-by-id handler; resolve the id against the page list.
|
|
26
|
+
api.listPages()
|
|
27
|
+
.then((rows) => {
|
|
28
|
+
if (!live) return;
|
|
29
|
+
const found = rows.find((p) => p.id === params.pageId) ?? null;
|
|
30
|
+
setPage(found);
|
|
31
|
+
setMissing(!found);
|
|
32
|
+
})
|
|
33
|
+
.catch((e) => setError(errMsg(e)));
|
|
34
|
+
api.listBlockTypes().then((r) => live && setBlockTypes(r)).catch((e) => setError(errMsg(e)));
|
|
35
|
+
return () => { live = false; };
|
|
36
|
+
}, [api, params.pageId, setError]);
|
|
37
|
+
|
|
38
|
+
const tab: InspectorTab = INSPECTOR_TABS.includes(params.tab as InspectorTab) ? (params.tab as InspectorTab) : "settings";
|
|
39
|
+
const setTab = (t: InspectorTab) => navigate("page", { params: { pageId: params.pageId, tab: t }, replace: true });
|
|
40
|
+
|
|
41
|
+
if (missing) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="mx-auto flex max-w-[1200px] items-center gap-2 px-7 pt-8">
|
|
44
|
+
<p className="text-fg-subtle">Page not found.</p>
|
|
45
|
+
<Button variant="ghost" size="sm" onPress={() => navigate("home")}>← all pages</Button>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!page) return <div className="mx-auto max-w-[1200px] px-7 pt-8"><p className="text-fg-subtle">Loading…</p></div>;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<PageEditor
|
|
53
|
+
api={api}
|
|
54
|
+
page={page}
|
|
55
|
+
blockTypes={blockTypes}
|
|
56
|
+
tab={tab}
|
|
57
|
+
onTab={setTab}
|
|
58
|
+
onBack={() => navigate("home")}
|
|
59
|
+
onChange={setPage}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Account settings route (`/settings`).
|
|
2
|
+
|
|
3
|
+
import { createPage } from "@buzola/router";
|
|
4
|
+
import { useApp } from "../app-context";
|
|
5
|
+
import { SettingsView } from "../components";
|
|
6
|
+
|
|
7
|
+
export default createPage()
|
|
8
|
+
.route("/settings")
|
|
9
|
+
.render(function Settings() {
|
|
10
|
+
const { api, cfg, me, setError, reconfigure } = useApp();
|
|
11
|
+
return <SettingsView api={api} cfg={cfg} me={me} onSignOut={reconfigure} onError={setError} />;
|
|
12
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Users management route (`/users`) — admin only. The tab is hidden for non-admins, but
|
|
2
|
+
// the route guards independently (a direct deep link by a non-admin gets a notice, and
|
|
3
|
+
// the server enforces the ACL regardless).
|
|
4
|
+
|
|
5
|
+
import { createPage, useNavigate } from "@buzola/router";
|
|
6
|
+
import { Button } from "@podoba/react";
|
|
7
|
+
import { useApp } from "../app-context";
|
|
8
|
+
import { UsersView } from "../components";
|
|
9
|
+
|
|
10
|
+
export default createPage()
|
|
11
|
+
.route("/users")
|
|
12
|
+
.render(function Users() {
|
|
13
|
+
const { api, me, isAdmin, setError } = useApp();
|
|
14
|
+
const navigate = useNavigate();
|
|
15
|
+
|
|
16
|
+
if (me === null) return <div className="mx-auto max-w-[1200px] px-7 pt-8"><p className="text-fg-subtle">Loading…</p></div>;
|
|
17
|
+
if (!isAdmin) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="mx-auto flex max-w-[1200px] items-center gap-2 px-7 pt-8">
|
|
20
|
+
<p className="text-fg-subtle">Admins only.</p>
|
|
21
|
+
<Button variant="ghost" size="sm" onPress={() => navigate("home")}>← back to pages</Button>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return <UsersView api={api} me={me} onError={setError} />;
|
|
26
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The Bun plugin (`@buzola/bun-plugin`) resolves `virtual:buzola/routes` at build time,
|
|
2
|
+
// re-exporting from the generated `buzola.gen.ts`. This declaration lets tsc resolve the
|
|
3
|
+
// same import against the committed generated file.
|
|
4
|
+
declare module "virtual:buzola/routes" {
|
|
5
|
+
export { pageRegistry, routes } from "./buzola.gen";
|
|
6
|
+
}
|