@pramen/admin 0.0.40 → 0.0.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/admin",
3
- "version": "0.0.40",
3
+ "version": "0.0.42",
4
4
  "description": "pramen admin dashboard — a Preact single-page browser client for the pramen admin HTTP API (tenants, schema, and generic row browse/edit).",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -21,15 +21,17 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "@podoba/react": "^0.0.3",
25
- "@podoba/tokens": "^0.0.3",
26
- "@podoba/tailwind": "^0.0.3",
24
+ "@podoba/react": "^0.0.32",
25
+ "@podoba/tokens": "^0.0.32",
26
+ "@podoba/tailwind": "^0.0.32",
27
27
  "react": "^19.0.0",
28
28
  "react-dom": "^19.0.0"
29
29
  },
30
30
  "devDependencies": {
31
+ "@tailwindcss/cli": "^4.0.0",
32
+ "@tailwindcss/typography": "^0.5.16",
31
33
  "@types/react": "^19.0.0",
32
34
  "@types/react-dom": "^19.0.0",
33
- "tailwindcss": "^3.4.19"
35
+ "tailwindcss": "^4.0.0"
34
36
  }
35
37
  }
package/scripts/build.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // mangles pure re-export barrels; app code bundles fine). Pass --watch to rebuild
4
4
  // on change and serve a preview on http://localhost:5174.
5
5
 
6
- import { rm, mkdir, writeFile, copyFile } from "node:fs/promises";
6
+ import { rm, mkdir, writeFile, copyFile, cp } from "node:fs/promises";
7
7
 
8
8
  const root = new URL("..", import.meta.url).pathname;
9
9
  const dist = `${root}dist`;
@@ -16,7 +16,7 @@ const html = (jsName: string) => `<!doctype html>
16
16
  <meta charset="utf-8" />
17
17
  <meta name="viewport" content="width=device-width, initial-scale=1" />
18
18
  <title>pramen · admin</title>
19
- <link rel="stylesheet" href="./tokens.css" />
19
+ <link rel="stylesheet" href="./fonts.css" />
20
20
  <link rel="stylesheet" href="./app.css" />
21
21
  </head>
22
22
  <body>
@@ -26,14 +26,18 @@ const html = (jsName: string) => `<!doctype html>
26
26
  </html>
27
27
  `;
28
28
 
29
- // Design system = podoba. Compile Tailwind (podoba preset) → dist/app.css and
30
- // copy the podoba token CSS vars dist/tokens.css; index.html <link>s both.
29
+ // Design system = podoba (Tailwind v4). app.css @imports tailwindcss + the podoba
30
+ // token vars + @theme, so the compiled dist/app.css is self-contained (no separate
31
+ // tokens.css). The NC Fontina web font ships as CSS + a woff2 in @podoba/tokens; we
32
+ // copy both to dist/ and <link> fonts.css directly, so its `url('./fonts/…')` stays
33
+ // relative to dist/ (no bundler asset-rebasing needed).
31
34
  async function styles(): Promise<void> {
32
- await copyFile(`${root}node_modules/@podoba/tokens/src/variables.css`, `${dist}/tokens.css`);
33
- const args = ["tailwindcss", "-c", `${root}tailwind.config.ts`, "-i", `${root}src/app.css`, "-o", `${dist}/app.css`];
35
+ const args = ["@tailwindcss/cli", "-i", `${root}src/app.css`, "-o", `${dist}/app.css`];
34
36
  if (!watch) args.push("--minify");
35
37
  const proc = Bun.spawn(["bunx", ...args], { cwd: root, stdout: "inherit", stderr: "inherit" });
36
38
  if ((await proc.exited) !== 0) throw new Error("tailwind build failed");
39
+ await cp(`${root}node_modules/@podoba/tokens/src/fonts`, `${dist}/fonts`, { recursive: true });
40
+ await copyFile(`${root}node_modules/@podoba/tokens/src/fonts.css`, `${dist}/fonts.css`);
37
41
  }
38
42
 
39
43
  async function build(): Promise<void> {
package/src/app.css CHANGED
@@ -1,3 +1,12 @@
1
- @tailwind base;
2
- @tailwind components;
3
- @tailwind utilities;
1
+ /* Design system = podoba (Tailwind v4, CSS-first). Order matters: Tailwind first,
2
+ * then @podoba/tokens' CSS variables, then @podoba/tailwind's `@theme inline` that
3
+ * binds those vars to utilities (bg-surface, text-fg-muted, rounded-panel, …). */
4
+ @import "tailwindcss";
5
+ @import "@podoba/tokens/variables.css";
6
+ @import "@podoba/tailwind";
7
+
8
+ /* v4 auto-detects this app's sources, but node_modules is ignored by default —
9
+ * @podoba/react ships TS source with class strings the compiler must scan, so name
10
+ * it explicitly (paths are relative to this file). */
11
+ @source "./**/*.{ts,tsx}";
12
+ @source "../node_modules/@podoba/react/src/**/*.{ts,tsx}";
package/src/app.tsx CHANGED
@@ -1,18 +1,19 @@
1
- // The pramen admin dashboard. A single focused inspector: config bar -> tenant
2
- // vessel -> schema strata (tables) -> row browser, with view/edit/create/delete
3
- // over the /admin/data primitives. Editing is a robust JSON textarea (no schema
4
- // guessing). All API failures render verbatim (error + code) in a banner.
1
+ // The pramen admin dashboard. A single focused inspector: connection + tenant ->
2
+ // schema strata (tables) -> row browser, with view/edit/create/delete over the
3
+ // /admin/data primitives. Editing is a robust JSON textarea (no schema guessing).
4
+ // All API failures render verbatim (error + code) in a banner.
5
+ //
6
+ // Design system = podoba (@podoba/react + tokens). The chrome is podoba's AppShell
7
+ // (topbar + sidebar + main); data uses its Table/Dialog/Select/Card/Badge. Dark mode
8
+ // is free — the token vars flip under `[data-theme="dark"]`.
5
9
 
6
- import { Button, Input } from "@podoba/react";
10
+ import { AppShell, Badge, Button, Card, Dialog, Heading, Input, MoonIcon, Select, SelectItem, SunIcon, Table, Text, Topbar, type TableColumn } from "@podoba/react";
7
11
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
12
  import { api, ApiError, type Config, type Row, type SchemaResult } from "./api";
9
13
 
10
- const LS = { base: "pramen.admin.baseUrl", token: "pramen.admin.token", tenant: "pramen.admin.tenant" };
14
+ const LS = { base: "pramen.admin.baseUrl", token: "pramen.admin.token", tenant: "pramen.admin.tenant", theme: "pramen.admin.theme" };
11
15
  const PAGE = 25;
12
16
 
13
- // Tokenized bare control (podoba filled-field skin) for the native inputs/selects.
14
- 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";
15
-
16
17
  const useLocal = (key: string, initial: string): [string, (v: string) => void] => {
17
18
  const [v, setV] = useState(() => localStorage.getItem(key) ?? initial);
18
19
  const set = useCallback(
@@ -31,7 +32,7 @@ function renderCell(v: unknown) {
31
32
  if (typeof v === "object") {
32
33
  const s = JSON.stringify(v);
33
34
  return (
34
- <span className="block max-w-[280px] truncate font-mono text-xs text-fg-muted" title={s}>
35
+ <span className="block max-w-[280px] truncate font-mono text-caption text-fg-muted" title={s}>
35
36
  {s}
36
37
  </span>
37
38
  );
@@ -50,6 +51,12 @@ export function App() {
50
51
  const [baseUrl, setBaseUrl] = useLocal(LS.base, "http://localhost:8787");
51
52
  const [token, setToken] = useLocal(LS.token, "");
52
53
  const [tenant, setTenant] = useLocal(LS.tenant, "main");
54
+ const [theme, setTheme] = useLocal(LS.theme, "light");
55
+
56
+ // podoba tokens flip on `[data-theme="dark"]` — no `dark:` prefixes needed.
57
+ useEffect(() => {
58
+ document.documentElement.dataset.theme = theme === "dark" ? "dark" : "light";
59
+ }, [theme]);
53
60
 
54
61
  const cfg: Config = useMemo(() => ({ baseUrl, token }), [baseUrl, token]);
55
62
 
@@ -209,174 +216,247 @@ export function App() {
209
216
  const tableNames = schema ? Object.keys(schema.tables) : [];
210
217
  const page = Math.floor(offset / PAGE) + 1;
211
218
  const totalPages = count != null ? Math.max(1, Math.ceil(count / PAGE)) : null;
219
+ // Manual tenants (not in the discovered list) still show in the Select as an option.
220
+ const tenantOptions = tenants.includes(tenant) || !tenant ? tenants : [tenant, ...tenants];
212
221
 
213
- return (
214
- <>
215
- <div className="flex flex-wrap items-end gap-4 border-b border-border bg-surface px-7 py-4">
216
- <div className="flex items-baseline gap-1.5">
217
- <span className="text-[15px] font-bold text-fg">pramen</span>
218
- <span className="text-fg-subtle">admin</span>
219
- </div>
220
- <div className="min-w-[220px] flex-1">
221
- <Input label="base url" value={baseUrl} onChange={setBaseUrl} placeholder="http://localhost:8787" spellCheck="false" />
222
+ // Row table = one column per schema field + a trailing actions column. Rebuilt each
223
+ // render so the edit/del closures capture current cfg/tenant/table (cheap; few cols).
224
+ const tableColumns: TableColumn<Row>[] = [
225
+ ...columns.map((c) => ({ key: c, header: c, render: (row: Row) => renderCell(row[c]) })),
226
+ {
227
+ key: "__actions",
228
+ header: "actions",
229
+ align: "right" as const,
230
+ render: (row: Row) => (
231
+ <div className="flex justify-end gap-1">
232
+ <Button variant="ghost" size="sm" onPress={() => void openEdit(row)}>
233
+ edit
234
+ </Button>
235
+ <Button variant="destructive" size="sm" isDisabled={busy} onPress={() => void del(row)}>
236
+ del
237
+ </Button>
222
238
  </div>
223
- <div className="min-w-[260px] flex-1">
224
- <Input label="admin token" type="password" value={token} onChange={setToken} placeholder='paste an admin JWT (roles include "admin")' spellCheck="false" />
225
- </div>
226
- <div className="w-full text-xs text-fg-subtle">Must be an admin token — every endpoint checks roles for &quot;admin&quot;.</div>
239
+ ),
240
+ },
241
+ ];
242
+
243
+ const sidebar = (
244
+ <div className="flex flex-col gap-6 p-5">
245
+ <div className="flex flex-col gap-3">
246
+ <Input label="base url" size="sm" value={baseUrl} onChange={setBaseUrl} placeholder="http://localhost:8787" />
247
+ <Input label="admin token" size="sm" type="password" value={token} onChange={setToken} placeholder="paste an admin JWT" />
248
+ <Text size="caption" tone="subtle">
249
+ Every endpoint checks roles for &quot;admin&quot;.
250
+ </Text>
227
251
  </div>
228
252
 
229
- <div className="grid grid-cols-[260px_1fr] gap-5 px-7 py-5 max-[820px]:grid-cols-1">
230
- <aside className="flex flex-col gap-2">
231
- <p className="text-sm text-fg-subtle">Tenant</p>
232
- <div className="flex items-center gap-1.5">
233
- {tenants.length > 0 ? (
234
- <select className={CONTROL} value={tenants.includes(tenant) ? tenant : ""} onChange={(e) => setTenant(e.target.value)}>
235
- {!tenants.includes(tenant) && <option value="">{tenant} (manual)</option>}
236
- {tenants.map((t) => (
237
- <option key={t} value={t}>{t}</option>
238
- ))}
239
- </select>
240
- ) : (
241
- <input className={CONTROL} value={tenant} onChange={(e) => setTenant(e.target.value)} placeholder="main" spellCheck={false} />
242
- )}
243
- <Button variant="ghost" size="sm" className="shrink-0" onPress={() => {
253
+ {tenantOptions.length > 0 ? (
254
+ <div className="flex items-end gap-1.5">
255
+ <div className="min-w-0 flex-1">
256
+ <Select
257
+ label="Tenant"
258
+ placeholder="select a tenant"
259
+ selectedKey={tenant || null}
260
+ onSelectionChange={(k) => setTenant(String(k))}
261
+ >
262
+ {tenantOptions.map((t) => (
263
+ <SelectItem key={t} id={t}>
264
+ {t === tenant && !tenants.includes(t) ? `${t} (manual)` : t}
265
+ </SelectItem>
266
+ ))}
267
+ </Select>
268
+ </div>
269
+ <Button
270
+ variant="ghost"
271
+ size="sm"
272
+ className="shrink-0"
273
+ onPress={() => {
244
274
  const next = prompt("Tenant name", tenant);
245
275
  if (next != null && next.trim()) setTenant(next.trim());
246
- }}>edit</Button>
247
- </div>
276
+ }}
277
+ >
278
+ edit
279
+ </Button>
280
+ </div>
281
+ ) : (
282
+ <Input label="Tenant" size="sm" value={tenant} onChange={setTenant} placeholder="main" />
283
+ )}
248
284
 
249
- <p className="mt-3 text-sm text-fg-subtle">Schema</p>
250
- {schema ? (
251
- <div className={`flex items-center gap-2 rounded-full border border-border px-3 py-1 font-mono text-xs ${schema.hash ? "text-fg-muted" : "text-fg-subtle"}`} title="applied schema hash">
252
- <span className={`h-2 w-2 rounded-full ${schema.hash ? "bg-brand-green" : "bg-fg-subtle"}`} />
253
- {schema.hash ? schema.hash.slice(0, 12) : "no schema hash"}
254
- </div>
255
- ) : (
256
- <div className="mb-3.5 text-fg-subtle">{token ? "—" : "enter a token"}</div>
257
- )}
258
-
259
- <p className="mt-3 text-sm text-fg-subtle">Tables{tableNames.length ? ` · ${tableNames.length}` : ""}</p>
260
- {tableNames.length > 0 ? (
261
- <ul className="flex flex-col gap-1">
262
- {tableNames.map((t) => (
263
- <li key={t}>
264
- <button
265
- className={`flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${t === table ? "border-fg bg-surface-card text-fg" : "border-transparent text-fg-muted hover:bg-surface-card"}`}
266
- onClick={() => setTable(t)}
267
- >
268
- <span className="font-medium">{t}</span>
269
- <span className="text-xs text-fg-subtle">{schema!.tables[t].length} cols</span>
270
- </button>
271
- </li>
272
- ))}
273
- </ul>
274
- ) : (
275
- <div className="text-fg-subtle">{token ? "no tables" : ""}</div>
276
- )}
277
- </aside>
278
-
279
- <main className="min-w-0">
280
- {err && (
281
- <div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-danger bg-surface-card px-4 py-2.5 text-sm text-danger">
282
- <div>
283
- {err.message} <code className="font-mono">{err.code}</code>
284
- </div>
285
- <Button variant="ghost" size="sm" onPress={() => setErr(null)}>dismiss</Button>
286
- </div>
287
- )}
285
+ <div className="flex flex-col gap-2">
286
+ <Text size="caption" tone="muted" className="font-medium uppercase tracking-wide">
287
+ Schema
288
+ </Text>
289
+ {schema ? (
290
+ <div className="flex items-center gap-2 rounded-full border border-border px-3 py-1 font-mono text-caption" title="applied schema hash">
291
+ <span className={`h-2 w-2 rounded-full ${schema.hash ? "bg-brand-green" : "bg-fg-subtle"}`} />
292
+ <span className={schema.hash ? "text-fg-muted" : "text-fg-subtle"}>{schema.hash ? schema.hash.slice(0, 12) : "no schema hash"}</span>
293
+ </div>
294
+ ) : (
295
+ <Text size="small" tone="subtle">
296
+ {token ? "—" : "enter a token"}
297
+ </Text>
298
+ )}
299
+ </div>
288
300
 
289
- {!table ? (
290
- <div className="rounded-panel border border-border bg-surface-card px-8 py-12 text-center">
291
- <div className="mb-2 text-xl text-fg">{token ? "select a table" : "paste an admin token to begin"}</div>
292
- <div className="text-fg-muted">The dashboard talks to the pramen Worker over HTTP. Tenant defaults to <code className="font-mono text-accent-strong">main</code>.</div>
293
- </div>
294
- ) : (
295
- <>
296
- <div className="mb-3 flex items-center gap-3">
297
- <h2 className="m-0 text-2xl font-normal text-fg">{table}</h2>
298
- <span className="rounded-full border border-border px-2.5 py-0.5 text-[11px] text-fg-muted">{count == null ? "…" : `${count} row${count === 1 ? "" : "s"}`}</span>
299
- <span className="flex-1" />
300
- <Button variant="ghost" size="sm" onPress={() => void loadRows()} isDisabled={loading}>refresh</Button>
301
- <Button size="sm" onPress={openCreate}>+ new row</Button>
302
- </div>
301
+ <div className="flex flex-col gap-2">
302
+ <Text size="caption" tone="muted" className="font-medium uppercase tracking-wide">
303
+ Tables{tableNames.length ? ` · ${tableNames.length}` : ""}
304
+ </Text>
305
+ {tableNames.length > 0 ? (
306
+ <ul className="flex flex-col gap-1">
307
+ {tableNames.map((t) => (
308
+ <li key={t}>
309
+ <button
310
+ className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-small transition-colors ${t === table ? "bg-surface-card font-medium text-fg" : "text-fg-muted hover:bg-surface-card"}`}
311
+ onClick={() => setTable(t)}
312
+ >
313
+ <span>{t}</span>
314
+ <Text size="caption" tone="subtle">
315
+ {schema!.tables[t].length}
316
+ </Text>
317
+ </button>
318
+ </li>
319
+ ))}
320
+ </ul>
321
+ ) : (
322
+ <Text size="small" tone="subtle">
323
+ {token ? "no tables" : ""}
324
+ </Text>
325
+ )}
326
+ </div>
327
+ </div>
328
+ );
303
329
 
304
- <div className="mb-3 flex items-center gap-2">
305
- <Button variant="secondary" size="sm" isDisabled={offset === 0 || loading} onPress={() => setOffset(Math.max(0, offset - PAGE))}>‹ prev</Button>
306
- <span className="text-sm text-fg-muted">
307
- page {page}
308
- {totalPages != null ? ` / ${totalPages}` : ""}
309
- </span>
310
- <Button variant="secondary" size="sm" isDisabled={loading || (count != null && offset + PAGE >= count)} onPress={() => setOffset(offset + PAGE)}>next ›</Button>
311
- </div>
330
+ const topbar = (
331
+ <Topbar className="px-6">
332
+ <Topbar.Brand>
333
+ <span className="text-callout font-bold text-fg">pramen</span>
334
+ <span className="text-fg-subtle">admin</span>
335
+ </Topbar.Brand>
336
+ <Topbar.Actions>
337
+ <Button
338
+ variant="ghost"
339
+ size="sm"
340
+ aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
341
+ onPress={() => setTheme(theme === "dark" ? "light" : "dark")}
342
+ >
343
+ {theme === "dark" ? <SunIcon className="h-4 w-4" /> : <MoonIcon className="h-4 w-4" />}
344
+ </Button>
345
+ </Topbar.Actions>
346
+ </Topbar>
347
+ );
312
348
 
313
- {loading && !rows ? (
314
- <div className="py-12 text-center text-fg-subtle">loading…</div>
315
- ) : rows && rows.length === 0 ? (
316
- <div className="py-12 text-center text-fg-subtle">no rows on this page</div>
317
- ) : rows ? (
318
- <div className="overflow-x-auto rounded-panel border border-border">
319
- <table className="w-full border-collapse text-sm">
320
- <thead>
321
- <tr>
322
- {columns.map((c) => (
323
- <th key={c} className="whitespace-nowrap border-b border-border bg-surface-card px-3 py-2 text-left font-medium text-fg-muted">{c}</th>
324
- ))}
325
- <th className="border-b border-border bg-surface-card px-3 py-2 text-right font-medium text-fg-muted">actions</th>
326
- </tr>
327
- </thead>
328
- <tbody>
329
- {rows.map((row, i) => (
330
- <tr key={(row.id as string) ?? i} className="hover:bg-surface-card">
331
- {columns.map((c) => (
332
- <td key={c} className="border-b border-border px-3 py-2 align-top">{renderCell(row[c])}</td>
333
- ))}
334
- <td className="border-b border-border px-3 py-2 align-top">
335
- <div className="flex justify-end gap-1">
336
- <Button variant="ghost" size="sm" onPress={() => void openEdit(row)}>edit</Button>
337
- <Button variant="ghost" size="sm" className="text-danger" onPress={() => void del(row)} isDisabled={busy}>del</Button>
338
- </div>
339
- </td>
340
- </tr>
341
- ))}
342
- </tbody>
343
- </table>
344
- </div>
345
- ) : null}
346
- </>
347
- )}
348
- </main>
349
- </div>
349
+ return (
350
+ <>
351
+ <AppShell topbar={topbar} sidebar={sidebar} sidebarLabel="Admin navigation" drawerToggleLabel="Toggle navigation">
352
+ {err && (
353
+ <Card variant="outlined" padding="none" className="mb-4 flex items-center justify-between gap-3 border-danger px-4 py-2.5">
354
+ <Text size="small" className="text-danger">
355
+ {err.message} <code className="font-mono">{err.code}</code>
356
+ </Text>
357
+ <Button variant="ghost" size="sm" onPress={() => setErr(null)}>
358
+ dismiss
359
+ </Button>
360
+ </Card>
361
+ )}
350
362
 
351
- {editor && (
352
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(20,15,5,0.28)] p-6" onClick={(e) => e.target === e.currentTarget && setEditor(null)}>
353
- <div className="flex max-h-[86vh] w-full max-w-[680px] flex-col overflow-hidden rounded-panel border border-border bg-surface-card shadow-[0_24px_60px_rgba(30,20,10,0.12)]">
354
- <div className="flex items-center gap-2 border-b border-border px-6 py-4">
355
- <h3 className="m-0 text-lg font-normal text-fg">{editor.mode === "create" ? `new ${table} row` : `edit ${table} · id=${JSON.stringify(editor.original?.id)}`}</h3>
363
+ {!table ? (
364
+ <Card variant="outlined" padding="lg" className="text-center">
365
+ <Heading level="3" className="mb-2">
366
+ {token ? "select a table" : "paste an admin token to begin"}
367
+ </Heading>
368
+ <Text tone="muted">
369
+ The dashboard talks to the pramen Worker over HTTP. Tenant defaults to <code className="font-mono text-accent-strong">main</code>.
370
+ </Text>
371
+ </Card>
372
+ ) : (
373
+ <>
374
+ <div className="mb-4 flex items-center gap-3">
375
+ <Heading level="2">{table}</Heading>
376
+ <Badge color="grey" label={count == null ? "…" : `${count} row${count === 1 ? "" : "s"}`} />
356
377
  <span className="flex-1" />
357
- <Button variant="ghost" size="sm" onPress={() => setEditor(null)}>✕</Button>
358
- </div>
359
- <div className="flex flex-col gap-2 overflow-auto px-6 py-4">
360
- <label className="text-sm font-medium text-fg">row json {editor.mode === "edit" ? "(only changed fields are sent as the patch)" : "(sent as values)"}</label>
361
- <textarea
362
- className="min-h-[280px] w-full resize-y rounded-lg border border-border bg-surface-card px-4 py-3 font-mono text-[13px] text-fg outline-none focus:border-brand-green"
363
- value={editor.text}
364
- spellCheck={false}
365
- onChange={(e) => setEditor({ ...editor, text: e.target.value })}
366
- />
367
- {editErr && <div className="mt-2 rounded-lg border border-danger bg-surface-card px-3.5 py-2.5 text-[13px] text-danger">{editErr}</div>}
378
+ <Button variant="ghost" size="sm" isDisabled={loading} onPress={() => void loadRows()}>
379
+ refresh
380
+ </Button>
381
+ <Button size="sm" onPress={openCreate}>
382
+ + new row
383
+ </Button>
368
384
  </div>
369
- <div className="flex items-center gap-2 border-t border-border px-6 py-4">
370
- <span className="text-fg-subtle">{editor.mode === "edit" ? "id is never modified" : "id is assigned by the server unless provided"}</span>
371
- <span className="flex-1" />
372
- <Button variant="ghost" onPress={() => setEditor(null)}>cancel</Button>
373
- <Button onPress={() => void saveEditor()} isDisabled={busy}>
374
- {busy ? "saving…" : editor.mode === "create" ? "create" : "save changes"}
385
+
386
+ <div className="mb-3 flex items-center gap-2">
387
+ <Button variant="secondary" size="sm" isDisabled={offset === 0 || loading} onPress={() => setOffset(Math.max(0, offset - PAGE))}>
388
+ prev
389
+ </Button>
390
+ <Text size="small" tone="muted">
391
+ page {page}
392
+ {totalPages != null ? ` / ${totalPages}` : ""}
393
+ </Text>
394
+ <Button variant="secondary" size="sm" isDisabled={loading || (count != null && offset + PAGE >= count)} onPress={() => setOffset(offset + PAGE)}>
395
+ next ›
375
396
  </Button>
376
397
  </div>
377
- </div>
378
- </div>
379
- )}
398
+
399
+ {loading && !rows ? (
400
+ <Text tone="subtle" className="block py-12 text-center">
401
+ loading…
402
+ </Text>
403
+ ) : rows ? (
404
+ <Card variant="outlined" padding="none" className="overflow-hidden">
405
+ <Table columns={tableColumns} data={rows} getRowKey={(r, i) => String((r.id as string) ?? i)} emptyMessage="no rows on this page" aria-label={`${table} rows`} />
406
+ </Card>
407
+ ) : null}
408
+ </>
409
+ )}
410
+ </AppShell>
411
+
412
+ <Dialog
413
+ isOpen={editor != null}
414
+ onOpenChange={(open) => {
415
+ if (!open) setEditor(null);
416
+ }}
417
+ size="lg"
418
+ title={editor ? (editor.mode === "create" ? `new ${table} row` : `edit ${table} · id=${JSON.stringify(editor.original?.id)}`) : undefined}
419
+ description={editor?.mode === "edit" ? "Only changed fields are sent as the patch." : editor ? "Sent as the row's values." : undefined}
420
+ >
421
+ {() =>
422
+ editor && (
423
+ <div className="flex flex-col gap-4">
424
+ <label className="flex flex-col gap-2">
425
+ <Text size="small" weight="medium">
426
+ row json
427
+ </Text>
428
+ {/* A code field: token-styled native textarea so JSON stays monospaced
429
+ (podoba's Textarea is a sans-serif form control). */}
430
+ <textarea
431
+ className="min-h-[300px] w-full resize-y rounded-lg border border-border bg-surface px-4 py-3 font-mono text-caption text-fg outline-none transition-colors focus:border-brand-green focus:ring-2 focus:ring-ring"
432
+ value={editor.text}
433
+ spellCheck={false}
434
+ onChange={(e) => setEditor({ ...editor, text: e.target.value })}
435
+ />
436
+ </label>
437
+ {editErr && (
438
+ <Card variant="outlined" padding="none" className="border-danger px-3.5 py-2.5">
439
+ <Text size="small" className="text-danger">
440
+ {editErr}
441
+ </Text>
442
+ </Card>
443
+ )}
444
+ <div className="flex items-center gap-2">
445
+ <Text size="small" tone="subtle">
446
+ {editor.mode === "edit" ? "id is never modified" : "id is assigned by the server unless provided"}
447
+ </Text>
448
+ <span className="flex-1" />
449
+ <Button variant="ghost" onPress={() => setEditor(null)}>
450
+ cancel
451
+ </Button>
452
+ <Button isDisabled={busy} onPress={() => void saveEditor()}>
453
+ {busy ? "saving…" : editor.mode === "create" ? "create" : "save changes"}
454
+ </Button>
455
+ </div>
456
+ </div>
457
+ )
458
+ }
459
+ </Dialog>
380
460
  </>
381
461
  );
382
462
  }