@pramen/admin 0.0.20 → 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/dist/app.css +1 -0
- package/dist/index.html +3 -1
- package/dist/main.a2yv3j94.js +251 -0
- package/dist/tokens.css +68 -0
- package/package.json +11 -2
- package/scripts/build.ts +14 -1
- package/src/app.css +3 -0
- package/src/app.tsx +91 -89
- package/src/main.tsx +9 -8
- package/dist/main.dm6bgf51.js +0 -185
- package/src/styles.ts +0 -190
package/dist/tokens.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* AUTO-GENERATED by @tools/sync-tokens — DO NOT EDIT BY HAND. */
|
|
2
|
+
/* Source of truth: the API → packages/tokens/src/tokens.json. */
|
|
3
|
+
/* Regenerate: bun --filter @tools/sync-tokens start */
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--color-accent-green-light: #b1fed4;
|
|
7
|
+
--color-accent-mint: #dff6ec;
|
|
8
|
+
--color-accent-pink: #ff69b4;
|
|
9
|
+
--color-accent-strong: #4bcf9f;
|
|
10
|
+
--color-accent-yellow: #ffcc6a;
|
|
11
|
+
--color-border: #eceae1;
|
|
12
|
+
--color-brand-green: #75e7b8;
|
|
13
|
+
--color-brand-primary: #0d0d0d;
|
|
14
|
+
--color-brand-secondary: #6eddb1;
|
|
15
|
+
--color-danger: #dc2626;
|
|
16
|
+
--color-danger-fg: #ffffff;
|
|
17
|
+
--color-fg: #0d0d0d;
|
|
18
|
+
--color-fg-inverted: #ffffff;
|
|
19
|
+
--color-fg-muted: #7d786f;
|
|
20
|
+
--color-fg-subtle: #aba89c;
|
|
21
|
+
--color-neutral-600: #333333;
|
|
22
|
+
--color-ring: #0d0d0d;
|
|
23
|
+
--color-success: #4bcf9f;
|
|
24
|
+
--color-success-fg: #0d0d0d;
|
|
25
|
+
--color-surface: #ffffff;
|
|
26
|
+
--color-surface-card: #f7f6f2;
|
|
27
|
+
--color-surface-inverted: #242423;
|
|
28
|
+
--color-surface-muted: #eceae1;
|
|
29
|
+
--color-surface-placeholder: #333333;
|
|
30
|
+
--font-mono: 'GT America Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
31
|
+
--font-sans: 'GT America', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
32
|
+
--font-size-body: 1rem;
|
|
33
|
+
--font-size-callout: 0.9375rem;
|
|
34
|
+
--font-size-caption: 0.6875rem;
|
|
35
|
+
--font-size-compact: 0.8125rem;
|
|
36
|
+
--font-size-display: 1.75rem;
|
|
37
|
+
--font-size-heading1: 1.75rem;
|
|
38
|
+
--font-size-heading2: 1.375rem;
|
|
39
|
+
--font-size-heading3: 1.25rem;
|
|
40
|
+
--font-size-heading4: 1.125rem;
|
|
41
|
+
--font-size-heading5: 1rem;
|
|
42
|
+
--font-size-headline: 1.375rem;
|
|
43
|
+
--font-size-label: 0.75rem;
|
|
44
|
+
--font-size-micro: 0.625rem;
|
|
45
|
+
--font-size-subtitle: 1.125rem;
|
|
46
|
+
--font-size-title: 1.25rem;
|
|
47
|
+
--line-height-heading1: 30px;
|
|
48
|
+
--line-height-heading2: 24px;
|
|
49
|
+
--line-height-heading3: 22px;
|
|
50
|
+
--line-height-heading4: 22px;
|
|
51
|
+
--line-height-heading5: 20px;
|
|
52
|
+
--radius-2xl: 1rem;
|
|
53
|
+
--radius-lg: 0.5rem;
|
|
54
|
+
--radius-md: 0.375rem;
|
|
55
|
+
--radius-panel: 1.1rem;
|
|
56
|
+
--radius-sm: 0.125rem;
|
|
57
|
+
--radius-xl: 0.75rem;
|
|
58
|
+
--space-lg: 1.5rem;
|
|
59
|
+
--space-md: 1rem;
|
|
60
|
+
--space-nav-x: 0.8125rem;
|
|
61
|
+
--space-sm: 0.5rem;
|
|
62
|
+
--space-xl: 2rem;
|
|
63
|
+
--space-xs: 0.25rem;
|
|
64
|
+
--tracking-normal: -0.13px;
|
|
65
|
+
--tracking-tight: -0.2px;
|
|
66
|
+
--tracking-wide: -0.56px;
|
|
67
|
+
--tracking-wider: -0.6px;
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/admin",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
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,6 +21,15 @@
|
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"
|
|
24
|
+
"@podoba/react": "^0.0.3",
|
|
25
|
+
"@podoba/tokens": "^0.0.3",
|
|
26
|
+
"@podoba/tailwind": "^0.0.3",
|
|
27
|
+
"react": "^19.0.0",
|
|
28
|
+
"react-dom": "^19.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/react": "^19.0.0",
|
|
32
|
+
"@types/react-dom": "^19.0.0",
|
|
33
|
+
"tailwindcss": "^3.4.19"
|
|
25
34
|
}
|
|
26
35
|
}
|
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 } from "node:fs/promises";
|
|
6
|
+
import { rm, mkdir, writeFile, copyFile } from "node:fs/promises";
|
|
7
7
|
|
|
8
8
|
const root = new URL("..", import.meta.url).pathname;
|
|
9
9
|
const dist = `${root}dist`;
|
|
@@ -16,6 +16,8 @@ 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" />
|
|
20
|
+
<link rel="stylesheet" href="./app.css" />
|
|
19
21
|
</head>
|
|
20
22
|
<body>
|
|
21
23
|
<div id="app"></div>
|
|
@@ -24,7 +26,18 @@ const html = (jsName: string) => `<!doctype html>
|
|
|
24
26
|
</html>
|
|
25
27
|
`;
|
|
26
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.
|
|
31
|
+
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`];
|
|
34
|
+
if (!watch) args.push("--minify");
|
|
35
|
+
const proc = Bun.spawn(["bunx", ...args], { cwd: root, stdout: "inherit", stderr: "inherit" });
|
|
36
|
+
if ((await proc.exited) !== 0) throw new Error("tailwind build failed");
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
async function build(): Promise<void> {
|
|
40
|
+
await styles();
|
|
28
41
|
const out = await Bun.build({
|
|
29
42
|
entrypoints: [entry],
|
|
30
43
|
outdir: dist,
|
package/src/app.css
ADDED
package/src/app.tsx
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
// over the /admin/data primitives. Editing is a robust JSON textarea (no schema
|
|
4
4
|
// guessing). All API failures render verbatim (error + code) in a banner.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { Button, Input } from "@podoba/react";
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
8
|
import { api, ApiError, type Config, type Row, type SchemaResult } from "./api";
|
|
8
9
|
|
|
9
10
|
const LS = { base: "pramen.admin.baseUrl", token: "pramen.admin.token", tenant: "pramen.admin.tenant" };
|
|
10
11
|
const PAGE = 25;
|
|
11
12
|
|
|
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
|
+
|
|
12
16
|
const useLocal = (key: string, initial: string): [string, (v: string) => void] => {
|
|
13
17
|
const [v, setV] = useState(() => localStorage.getItem(key) ?? initial);
|
|
14
18
|
const set = useCallback(
|
|
@@ -22,19 +26,19 @@ const useLocal = (key: string, initial: string): [string, (v: string) => void] =
|
|
|
22
26
|
};
|
|
23
27
|
|
|
24
28
|
function renderCell(v: unknown) {
|
|
25
|
-
if (v === null || v === undefined) return <span
|
|
26
|
-
if (typeof v === "boolean") return <span
|
|
29
|
+
if (v === null || v === undefined) return <span className="italic text-fg-subtle">null</span>;
|
|
30
|
+
if (typeof v === "boolean") return <span className="text-accent-strong">{String(v)}</span>;
|
|
27
31
|
if (typeof v === "object") {
|
|
28
32
|
const s = JSON.stringify(v);
|
|
29
33
|
return (
|
|
30
|
-
<span
|
|
34
|
+
<span className="block max-w-[280px] truncate font-mono text-xs text-fg-muted" title={s}>
|
|
31
35
|
{s}
|
|
32
36
|
</span>
|
|
33
37
|
);
|
|
34
38
|
}
|
|
35
39
|
const s = String(v);
|
|
36
40
|
return (
|
|
37
|
-
<span
|
|
41
|
+
<span className="block max-w-[280px] truncate" title={s}>
|
|
38
42
|
{s}
|
|
39
43
|
</span>
|
|
40
44
|
);
|
|
@@ -109,7 +113,7 @@ export function App() {
|
|
|
109
113
|
}, [cfg, tenant, table, offset, token, report]);
|
|
110
114
|
|
|
111
115
|
// tenants + schema react to config/tenant; rows react to table/page.
|
|
112
|
-
const debounce = useRef<ReturnType<typeof setTimeout
|
|
116
|
+
const debounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
113
117
|
useEffect(() => {
|
|
114
118
|
clearTimeout(debounce.current);
|
|
115
119
|
debounce.current = setTimeout(() => {
|
|
@@ -208,132 +212,129 @@ export function App() {
|
|
|
208
212
|
|
|
209
213
|
return (
|
|
210
214
|
<>
|
|
211
|
-
<div
|
|
212
|
-
<div
|
|
213
|
-
<span
|
|
214
|
-
<span
|
|
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>
|
|
215
219
|
</div>
|
|
216
|
-
<div
|
|
217
|
-
<label
|
|
218
|
-
<input value={baseUrl} onInput={(e) => setBaseUrl((e.target as HTMLInputElement).value)} placeholder="http://localhost:8787" spellcheck={false} />
|
|
220
|
+
<div className="min-w-[220px] flex-1">
|
|
221
|
+
<Input label="base url" value={baseUrl} onChange={setBaseUrl} placeholder="http://localhost:8787" spellCheck="false" />
|
|
219
222
|
</div>
|
|
220
|
-
<div
|
|
221
|
-
<label
|
|
222
|
-
<input type="password" value={token} onInput={(e) => setToken((e.target as HTMLInputElement).value)} placeholder="paste an admin JWT (roles include "admin")" spellcheck={false} />
|
|
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" />
|
|
223
225
|
</div>
|
|
224
|
-
<div
|
|
226
|
+
<div className="w-full text-xs text-fg-subtle">Must be an admin token — every endpoint checks roles for "admin".</div>
|
|
225
227
|
</div>
|
|
226
228
|
|
|
227
|
-
<div
|
|
228
|
-
<aside
|
|
229
|
-
<p
|
|
230
|
-
<div
|
|
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">
|
|
231
233
|
{tenants.length > 0 ? (
|
|
232
|
-
<select value={tenants.includes(tenant) ? tenant : ""} onChange={(e) => setTenant(
|
|
234
|
+
<select className={CONTROL} value={tenants.includes(tenant) ? tenant : ""} onChange={(e) => setTenant(e.target.value)}>
|
|
233
235
|
{!tenants.includes(tenant) && <option value="">{tenant} (manual)</option>}
|
|
234
236
|
{tenants.map((t) => (
|
|
235
|
-
<option key={t} value={t}>
|
|
236
|
-
{t}
|
|
237
|
-
</option>
|
|
237
|
+
<option key={t} value={t}>{t}</option>
|
|
238
238
|
))}
|
|
239
239
|
</select>
|
|
240
240
|
) : (
|
|
241
|
-
<input value={tenant}
|
|
241
|
+
<input className={CONTROL} value={tenant} onChange={(e) => setTenant(e.target.value)} placeholder="main" spellCheck={false} />
|
|
242
242
|
)}
|
|
243
|
-
<
|
|
243
|
+
<Button variant="ghost" size="sm" className="shrink-0" onPress={() => {
|
|
244
244
|
const next = prompt("Tenant name", tenant);
|
|
245
245
|
if (next != null && next.trim()) setTenant(next.trim());
|
|
246
|
-
}}>edit</
|
|
246
|
+
}}>edit</Button>
|
|
247
247
|
</div>
|
|
248
248
|
|
|
249
|
-
<p
|
|
249
|
+
<p className="mt-3 text-sm text-fg-subtle">Schema</p>
|
|
250
250
|
{schema ? (
|
|
251
|
-
<div
|
|
252
|
-
<span
|
|
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
253
|
{schema.hash ? schema.hash.slice(0, 12) : "no schema hash"}
|
|
254
254
|
</div>
|
|
255
255
|
) : (
|
|
256
|
-
<div
|
|
256
|
+
<div className="mb-3.5 text-fg-subtle">{token ? "—" : "enter a token"}</div>
|
|
257
257
|
)}
|
|
258
258
|
|
|
259
|
-
<p
|
|
259
|
+
<p className="mt-3 text-sm text-fg-subtle">Tables{tableNames.length ? ` · ${tableNames.length}` : ""}</p>
|
|
260
260
|
{tableNames.length > 0 ? (
|
|
261
|
-
<ul
|
|
261
|
+
<ul className="flex flex-col gap-1">
|
|
262
262
|
{tableNames.map((t) => (
|
|
263
263
|
<li key={t}>
|
|
264
|
-
<button
|
|
265
|
-
|
|
266
|
-
|
|
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>
|
|
267
270
|
</button>
|
|
268
271
|
</li>
|
|
269
272
|
))}
|
|
270
273
|
</ul>
|
|
271
274
|
) : (
|
|
272
|
-
<div
|
|
275
|
+
<div className="text-fg-subtle">{token ? "no tables" : ""}</div>
|
|
273
276
|
)}
|
|
274
277
|
</aside>
|
|
275
278
|
|
|
276
|
-
<main
|
|
279
|
+
<main className="min-w-0">
|
|
277
280
|
{err && (
|
|
278
|
-
<div
|
|
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">
|
|
279
282
|
<div>
|
|
280
|
-
{err.message} <code>{err.code}</code>
|
|
283
|
+
{err.message} <code className="font-mono">{err.code}</code>
|
|
281
284
|
</div>
|
|
282
|
-
<
|
|
285
|
+
<Button variant="ghost" size="sm" onPress={() => setErr(null)}>dismiss</Button>
|
|
283
286
|
</div>
|
|
284
287
|
)}
|
|
285
288
|
|
|
286
289
|
{!table ? (
|
|
287
|
-
<div
|
|
288
|
-
<div
|
|
289
|
-
<div>The dashboard talks to the pramen Worker over HTTP. Tenant defaults to <code
|
|
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>
|
|
290
293
|
</div>
|
|
291
294
|
) : (
|
|
292
295
|
<>
|
|
293
|
-
<div
|
|
294
|
-
<h2>{table}</h2>
|
|
295
|
-
<span
|
|
296
|
-
<span
|
|
297
|
-
<
|
|
298
|
-
<
|
|
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>
|
|
299
302
|
</div>
|
|
300
303
|
|
|
301
|
-
<div
|
|
302
|
-
<
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
<button class="sm" disabled={loading || (count != null && offset + PAGE >= count)} onClick={() => setOffset(offset + PAGE)}>next ›</button>
|
|
309
|
-
</div>
|
|
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>
|
|
310
311
|
</div>
|
|
311
312
|
|
|
312
313
|
{loading && !rows ? (
|
|
313
|
-
<div
|
|
314
|
+
<div className="py-12 text-center text-fg-subtle">loading…</div>
|
|
314
315
|
) : rows && rows.length === 0 ? (
|
|
315
|
-
<div
|
|
316
|
+
<div className="py-12 text-center text-fg-subtle">no rows on this page</div>
|
|
316
317
|
) : rows ? (
|
|
317
|
-
<div
|
|
318
|
-
<table
|
|
318
|
+
<div className="overflow-x-auto rounded-panel border border-border">
|
|
319
|
+
<table className="w-full border-collapse text-sm">
|
|
319
320
|
<thead>
|
|
320
321
|
<tr>
|
|
321
322
|
{columns.map((c) => (
|
|
322
|
-
<th key={c}>{c}</th>
|
|
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>
|
|
323
324
|
))}
|
|
324
|
-
<th
|
|
325
|
+
<th className="border-b border-border bg-surface-card px-3 py-2 text-right font-medium text-fg-muted">actions</th>
|
|
325
326
|
</tr>
|
|
326
327
|
</thead>
|
|
327
328
|
<tbody>
|
|
328
329
|
{rows.map((row, i) => (
|
|
329
|
-
<tr key={(row.id as string) ?? i}>
|
|
330
|
+
<tr key={(row.id as string) ?? i} className="hover:bg-surface-card">
|
|
330
331
|
{columns.map((c) => (
|
|
331
|
-
<td key={c}>{renderCell(row[c])}</td>
|
|
332
|
+
<td key={c} className="border-b border-border px-3 py-2 align-top">{renderCell(row[c])}</td>
|
|
332
333
|
))}
|
|
333
|
-
<td>
|
|
334
|
-
<div
|
|
335
|
-
<
|
|
336
|
-
<
|
|
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>
|
|
337
338
|
</div>
|
|
338
339
|
</td>
|
|
339
340
|
</tr>
|
|
@@ -348,29 +349,30 @@ export function App() {
|
|
|
348
349
|
</div>
|
|
349
350
|
|
|
350
351
|
{editor && (
|
|
351
|
-
<div
|
|
352
|
-
<div
|
|
353
|
-
<div
|
|
354
|
-
<h3>{editor.mode === "create" ? `new ${table} row` : `edit ${table} · id=${JSON.stringify(editor.original?.id)}`}</h3>
|
|
355
|
-
<span
|
|
356
|
-
<
|
|
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>
|
|
356
|
+
<span className="flex-1" />
|
|
357
|
+
<Button variant="ghost" size="sm" onPress={() => setEditor(null)}>✕</Button>
|
|
357
358
|
</div>
|
|
358
|
-
<div
|
|
359
|
-
<label>row json {editor.mode === "edit" ? "(only changed fields are sent as the patch)" : "(sent as values)"}</label>
|
|
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>
|
|
360
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"
|
|
361
363
|
value={editor.text}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
+
spellCheck={false}
|
|
365
|
+
onChange={(e) => setEditor({ ...editor, text: e.target.value })}
|
|
364
366
|
/>
|
|
365
|
-
{editErr && <div
|
|
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>}
|
|
366
368
|
</div>
|
|
367
|
-
<div
|
|
368
|
-
<span
|
|
369
|
-
<span
|
|
370
|
-
<
|
|
371
|
-
<
|
|
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}>
|
|
372
374
|
{busy ? "saving…" : editor.mode === "create" ? "create" : "save changes"}
|
|
373
|
-
</
|
|
375
|
+
</Button>
|
|
374
376
|
</div>
|
|
375
377
|
</div>
|
|
376
378
|
</div>
|
package/src/main.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// SPA entry:
|
|
1
|
+
// SPA entry: mount the admin dashboard. Styling is podoba — @podoba/tokens/variables.css
|
|
2
|
+
// + the compiled Tailwind (podoba preset) are <link>ed by index.html (see scripts/build.ts).
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { StrictMode } from "react";
|
|
5
|
+
import { createRoot } from "react-dom/client";
|
|
4
6
|
import { App } from "./app";
|
|
5
|
-
import { css } from "./styles";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
createRoot(document.getElementById("app")!).render(
|
|
9
|
+
<StrictMode>
|
|
10
|
+
<App />
|
|
11
|
+
</StrictMode>,
|
|
12
|
+
);
|