@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/dist/app.css +2 -1
- package/dist/fonts/README.md +18 -0
- package/dist/fonts/nc-fontina-variable.woff2 +0 -0
- package/dist/fonts.css +45 -0
- package/dist/index.html +2 -2
- package/dist/main.nrxy8p7a.js +251 -0
- package/package.json +7 -5
- package/scripts/build.ts +10 -6
- package/src/app.css +12 -3
- package/src/app.tsx +243 -163
- package/dist/main.a2yv3j94.js +0 -251
- package/dist/tokens.css +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/admin",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
25
|
-
"@podoba/tokens": "^0.0.
|
|
26
|
-
"@podoba/tailwind": "^0.0.
|
|
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": "^
|
|
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="./
|
|
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
|
|
30
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
@tailwind
|
|
3
|
-
|
|
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:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 "admin".
|
|
250
|
+
</Text>
|
|
227
251
|
</div>
|
|
228
252
|
|
|
229
|
-
|
|
230
|
-
<
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
}}
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
<
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
<
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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={() =>
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
<
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
<
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
}
|