@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/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 { useEffect, useRef, useState } from "react";
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
- <span className="lbl">
22
- {def.label ?? def.name} {def.required ? <span className="req">*</span> : null}
23
- </span>
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
- <div className="field">
46
- {label}
49
+ <FieldShell label={label}>
47
50
  <RichText value={typeof value === "string" ? value : ""} onChange={onChange as (v: string) => void} />
48
- </div>
51
+ </FieldShell>
49
52
  );
50
53
  case "number":
51
54
  return (
52
- <label className="field">
53
- {label}
54
- <input type="number" value={value == null ? "" : String(value)} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} />
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 className="field">
61
- {label}
62
- <input type={def.type === "date" ? "date" : "datetime-local"} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)} />
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="field checkbox">
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 className="field">
75
- {label}
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
- </label>
82
+ </FieldShell>
85
83
  );
86
84
  case "media":
87
85
  return (
88
- <label className="field">
89
- {label}
86
+ <FieldShell label={label}>
90
87
  <MediaField value={value as string | null} onChange={onChange} api={api} />
91
- </label>
88
+ </FieldShell>
92
89
  );
93
90
  case "group":
94
91
  return (
95
- <div className="field">
96
- {label}
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
- </div>
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="rt">
188
- <div className="rt-toolbar">
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="sm ghost" title={t.title} onMouseDown={(e) => e.preventDefault()} onClick={() => t.run(exec)}>
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 ref={ref} className="rt-editor" contentEditable suppressContentEditableWarning data-placeholder="Write…" onInput={emit} onBlur={emit} onPaste={onPaste} />
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: React.ReactNode }) {
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="field">
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="repeater-item" key={i}>
218
- <div className="ih">
219
- <button type="button" className="ghost sm" onClick={() => move(i, -1)}>
220
-
221
- </button>
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
- <button type="button" className="sm" onClick={add} disabled={def.max != null && items.length >= def.max}>
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
- </button>
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="row" style={{ cursor: "default" }}>
249
- {media ? <img src={api.resolve(`/media/${media.file.key}`)} alt="" style={{ width: 40, height: 40, objectFit: "cover", borderRadius: 4 }} /> : <span className="muted">no media</span>}
250
- <span className="grow muted">{media?.file.filename ?? value ?? ""}</span>
251
- <button type="button" className="sm" onClick={() => setOpen(true)}>
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
- <button type="button" className="sm ghost danger" onClick={() => onChange(null)}>
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="scrim" onClick={onClose}>
297
- <div className="modal" onClick={(e) => e.stopPropagation()}>
298
- <h2>
299
- Choose <span className="dim">a file</span> from the library
300
- </h2>
301
- {err ? <div className="banner err">{err}</div> : null}
302
- <label className="field">
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="media-grid">
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="media-cell" onClick={() => onPick(m.id)}>
309
- {(m.file.contentType ?? "").startsWith("image/") ? <img src={api.resolve(`/media/${m.file.key}`)} alt="" /> : <div style={{ height: 70 }} />}
310
- <div className="fn">{m.file.filename ?? m.id}</div>
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 style={{ marginTop: 12, textAlign: "right" }}>
315
- <button className="ghost" onClick={onClose}>
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 { App } from "./app";
4
- import { css } from "./styles";
4
+ import { pageRegistry, routes } from "virtual:buzola/routes";
5
+ import { AppProvider } from "./app-context";
5
6
 
6
- const style = document.createElement("style");
7
- style.textContent = css;
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) createRoot(el).render(<StrictMode><App /></StrictMode>);
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&apos;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
+ }