@nbt-dev/components 0.0.5
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/LICENSE +177 -0
- package/README.md +10 -0
- package/TRADEMARKS.md +49 -0
- package/dist/chunk-3ZM6YOA4.js +704 -0
- package/dist/chunk-3ZM6YOA4.js.map +7 -0
- package/dist/chunk-7B2T5ZNG.js +467 -0
- package/dist/chunk-7B2T5ZNG.js.map +7 -0
- package/dist/chunk-S7VBQE6Y.js +636 -0
- package/dist/chunk-S7VBQE6Y.js.map +7 -0
- package/dist/chunk-UPEOXMLZ.js +625 -0
- package/dist/chunk-UPEOXMLZ.js.map +7 -0
- package/dist/core/auth.d.ts +13 -0
- package/dist/core/bulk-decoder.d.ts +13 -0
- package/dist/core/config.d.ts +10 -0
- package/dist/core/data-store.d.ts +20 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/use-bulk-stream.d.ts +24 -0
- package/dist/core/use-cartridge-info.d.ts +14 -0
- package/dist/core/utils.d.ts +2 -0
- package/dist/editor/index.d.ts +7 -0
- package/dist/editor/index.js +16 -0
- package/dist/editor/index.js.map +7 -0
- package/dist/editor/lsp-client.d.ts +57 -0
- package/dist/editor/lsp-extensions.d.ts +4 -0
- package/dist/editor/nbt-editor.d.ts +13 -0
- package/dist/editor/nbt-language.d.ts +7 -0
- package/dist/generated/bulk-protocol.d.ts +36 -0
- package/dist/graph/diagram.d.ts +5 -0
- package/dist/graph/entity-graph-utils.d.ts +92 -0
- package/dist/graph/entity-node.d.ts +9 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.js +19 -0
- package/dist/graph/index.js.map +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +134 -0
- package/dist/index.js.map +7 -0
- package/dist/styles.css +2 -0
- package/dist/table/data-table.d.ts +9 -0
- package/dist/table/index.d.ts +3 -0
- package/dist/table/index.js +11 -0
- package/dist/table/index.js.map +7 -0
- package/dist/table/value-popover.d.ts +18 -0
- package/package.json +77 -0
- package/src/core/auth.ts +100 -0
- package/src/core/bulk-decoder.ts +178 -0
- package/src/core/config.tsx +39 -0
- package/src/core/data-store.ts +113 -0
- package/src/core/index.ts +34 -0
- package/src/core/use-bulk-stream.ts +412 -0
- package/src/core/use-cartridge-info.ts +100 -0
- package/src/core/utils.ts +6 -0
- package/src/editor/index.ts +13 -0
- package/src/editor/lsp-client.ts +227 -0
- package/src/editor/lsp-extensions.ts +191 -0
- package/src/editor/nbt-editor.tsx +142 -0
- package/src/editor/nbt-language.ts +151 -0
- package/src/generated/bulk-protocol.ts +63 -0
- package/src/graph/diagram.tsx +296 -0
- package/src/graph/entity-graph-utils.ts +423 -0
- package/src/graph/entity-node.tsx +122 -0
- package/src/graph/index.ts +19 -0
- package/src/index.ts +7 -0
- package/src/styles.css +94 -0
- package/src/table/data-table.tsx +274 -0
- package/src/table/index.ts +5 -0
- package/src/table/value-popover.tsx +230 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useBulkSubscription } from "../core/use-bulk-stream";
|
|
3
|
+
import { cn } from "../core/utils";
|
|
4
|
+
import { TYPE_DATETIME, TYPE_DOCUMENT } from "../generated/bulk-protocol";
|
|
5
|
+
import ValuePopover, { type ValuePopoverData } from "./value-popover";
|
|
6
|
+
|
|
7
|
+
const ROW_H = 22;
|
|
8
|
+
const OVERSCAN = 6;
|
|
9
|
+
const MIN_COL_W = 80;
|
|
10
|
+
const GUTTER_W = 32;
|
|
11
|
+
|
|
12
|
+
const READONLY_FIELDS = new Set(["id", "createdAt", "updatedAt"]);
|
|
13
|
+
const READONLY_TYPES = new Set([TYPE_DATETIME, TYPE_DOCUMENT]);
|
|
14
|
+
|
|
15
|
+
type DataTableProps = {
|
|
16
|
+
cart: string;
|
|
17
|
+
entity: string;
|
|
18
|
+
searchFields: readonly string[];
|
|
19
|
+
onSelectRow: (rowJson: Record<string, string>) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function colWidth(name: string): number {
|
|
23
|
+
if (name === "id") return 220;
|
|
24
|
+
if (name === "createdAt" || name === "updatedAt") return 180;
|
|
25
|
+
if (name === "email" || name === "phone") return 180;
|
|
26
|
+
if (name.endsWith("At")) return 180;
|
|
27
|
+
return Math.max(MIN_COL_W, Math.min(240, name.length * 9 + 24));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DataTable: React.FC<DataTableProps> = ({ cart, entity, searchFields, onSelectRow }) => {
|
|
31
|
+
const stream = useBulkSubscription(cart, entity);
|
|
32
|
+
const scrollerRef = React.useRef<HTMLDivElement | null>(null);
|
|
33
|
+
const [scrollTop, setScrollTop] = React.useState(0);
|
|
34
|
+
const [viewportH, setViewportH] = React.useState(0);
|
|
35
|
+
const [query, setQuery] = React.useState("");
|
|
36
|
+
const [selected, setSelected] = React.useState<{ r: number; c: number } | null>(null);
|
|
37
|
+
const [popover, setPopover] = React.useState<ValuePopoverData | null>(null);
|
|
38
|
+
// Bump on every chunk arrival without re-rendering each FRAME_DATA.
|
|
39
|
+
const [tick, setTick] = React.useState(0);
|
|
40
|
+
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
stream.setOnRender(() => setTick((n) => n + 1));
|
|
43
|
+
return () => stream.setOnRender(null);
|
|
44
|
+
}, [stream]);
|
|
45
|
+
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
const el = scrollerRef.current;
|
|
48
|
+
if (!el) return;
|
|
49
|
+
const ro = new ResizeObserver(() => setViewportH(el.clientHeight));
|
|
50
|
+
ro.observe(el);
|
|
51
|
+
setViewportH(el.clientHeight);
|
|
52
|
+
return () => ro.disconnect();
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const rows = stream.store.rows;
|
|
56
|
+
const columns = stream.columns;
|
|
57
|
+
|
|
58
|
+
const totalH = rows.length * ROW_H;
|
|
59
|
+
const start = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN);
|
|
60
|
+
const visibleCount = Math.max(0, Math.ceil(viewportH / ROW_H) + OVERSCAN * 2);
|
|
61
|
+
const end = Math.min(rows.length, start + visibleCount);
|
|
62
|
+
|
|
63
|
+
const onSearchKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
64
|
+
if (e.key === "Enter") {
|
|
65
|
+
stream.search(query.trim());
|
|
66
|
+
} else if (e.key === "Escape") {
|
|
67
|
+
setQuery("");
|
|
68
|
+
stream.clearSearch();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleRowClick = (rowIdx: number) => {
|
|
73
|
+
const r = rows[rowIdx];
|
|
74
|
+
if (!r) return;
|
|
75
|
+
const out: Record<string, string> = {};
|
|
76
|
+
for (let i = 0; i < columns.length; i++) out[columns[i]!.name] = r[i] ?? "";
|
|
77
|
+
onSelectRow(out);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const searchDisabled = searchFields.length === 0;
|
|
81
|
+
const totalColW = GUTTER_W + columns.reduce((s, c) => s + colWidth(c.name), 0);
|
|
82
|
+
const idColIndex = columns.findIndex((c) => c.name === "id");
|
|
83
|
+
|
|
84
|
+
const scrollRowIntoView = (r: number) => {
|
|
85
|
+
const el = scrollerRef.current;
|
|
86
|
+
if (!el) return;
|
|
87
|
+
const top = r * ROW_H;
|
|
88
|
+
if (top < el.scrollTop) el.scrollTop = top;
|
|
89
|
+
else if (top + ROW_H > el.scrollTop + el.clientHeight)
|
|
90
|
+
el.scrollTop = top + ROW_H - el.clientHeight;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
94
|
+
if (rows.length === 0 || columns.length === 0) return;
|
|
95
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "c" || e.key === "C")) {
|
|
96
|
+
if (!selected) return;
|
|
97
|
+
const v = rows[selected.r]?.[selected.c] ?? "";
|
|
98
|
+
void navigator.clipboard?.writeText(v);
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const cur = selected ?? { r: start, c: 0 };
|
|
103
|
+
let { r, c } = cur;
|
|
104
|
+
if (e.key === "ArrowUp") r--;
|
|
105
|
+
else if (e.key === "ArrowDown") r++;
|
|
106
|
+
else if (e.key === "ArrowLeft") c--;
|
|
107
|
+
else if (e.key === "ArrowRight") c++;
|
|
108
|
+
else return;
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
r = Math.max(0, Math.min(rows.length - 1, r));
|
|
111
|
+
c = Math.max(0, Math.min(columns.length - 1, c));
|
|
112
|
+
setSelected({ r, c });
|
|
113
|
+
setPopover(null);
|
|
114
|
+
scrollRowIntoView(r);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex h-full flex-col bg-background">
|
|
119
|
+
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2">
|
|
120
|
+
<input
|
|
121
|
+
type="text"
|
|
122
|
+
value={query}
|
|
123
|
+
disabled={searchDisabled}
|
|
124
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
125
|
+
onKeyDown={onSearchKey}
|
|
126
|
+
placeholder={
|
|
127
|
+
searchDisabled
|
|
128
|
+
? "Search disabled (no @@search declared)"
|
|
129
|
+
: `Search ${searchFields.join(", ")} ⏎`
|
|
130
|
+
}
|
|
131
|
+
className={cn(
|
|
132
|
+
"h-5 flex-1 rounded-sm border border-border bg-background px-2 text-[12px] outline-none",
|
|
133
|
+
"placeholder:text-muted-foreground/70 focus:border-accent-foreground/30",
|
|
134
|
+
searchDisabled && "opacity-50 cursor-not-allowed",
|
|
135
|
+
)}
|
|
136
|
+
/>
|
|
137
|
+
<div className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
|
138
|
+
{stream.error ? (
|
|
139
|
+
<span className="text-red-400">{stream.error}</span>
|
|
140
|
+
) : !stream.connected ? (
|
|
141
|
+
<span>connecting…</span>
|
|
142
|
+
) : (
|
|
143
|
+
<span>
|
|
144
|
+
{stream.loadedRows.toLocaleString()} / {stream.totalRows.toLocaleString()} rows
|
|
145
|
+
{stream.streaming ? " · streaming" : ""}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{columns.length === 0 ? (
|
|
152
|
+
<div className="flex flex-1 items-center justify-center text-[12px] text-muted-foreground">
|
|
153
|
+
{stream.error ? stream.error : "Waiting for schema…"}
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<div
|
|
157
|
+
ref={scrollerRef}
|
|
158
|
+
tabIndex={0}
|
|
159
|
+
onKeyDown={onKeyDown}
|
|
160
|
+
onScroll={(e) => {
|
|
161
|
+
setScrollTop((e.target as HTMLDivElement).scrollTop);
|
|
162
|
+
if (popover) setPopover(null);
|
|
163
|
+
}}
|
|
164
|
+
className="group/table relative min-h-0 flex-1 overflow-auto font-mono outline-none"
|
|
165
|
+
>
|
|
166
|
+
<div style={{ width: totalColW }}>
|
|
167
|
+
{/* Sticky header lives inside the scroller so it shares the native
|
|
168
|
+
horizontal scroll with the rows — no JS transform sync, no jitter. */}
|
|
169
|
+
<div
|
|
170
|
+
className="sticky top-0 z-10 flex h-6 select-none border-b border-border bg-background text-[11px] uppercase tracking-wider text-muted-foreground"
|
|
171
|
+
style={{ width: totalColW }}
|
|
172
|
+
>
|
|
173
|
+
<div
|
|
174
|
+
style={{ width: GUTTER_W }}
|
|
175
|
+
className="shrink-0 border-r border-border"
|
|
176
|
+
/>
|
|
177
|
+
{columns.map((c) => (
|
|
178
|
+
<div
|
|
179
|
+
key={c.name}
|
|
180
|
+
style={{ width: colWidth(c.name) }}
|
|
181
|
+
className="flex items-center overflow-hidden border-r border-border px-2"
|
|
182
|
+
>
|
|
183
|
+
<span className="truncate">{c.name}</span>
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div style={{ height: totalH, width: totalColW, position: "relative" }}>
|
|
189
|
+
{rows.slice(start, end).map((row, i) => {
|
|
190
|
+
const rowIdx = start + i;
|
|
191
|
+
return (
|
|
192
|
+
<div
|
|
193
|
+
key={rowIdx}
|
|
194
|
+
style={{
|
|
195
|
+
top: rowIdx * ROW_H,
|
|
196
|
+
height: ROW_H,
|
|
197
|
+
width: totalColW,
|
|
198
|
+
}}
|
|
199
|
+
className={cn(
|
|
200
|
+
"absolute left-0 flex text-[12px] leading-none",
|
|
201
|
+
rowIdx % 2 === 0 ? "bg-background" : "bg-muted/20",
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
title="Open row detail"
|
|
207
|
+
onClick={() => handleRowClick(rowIdx)}
|
|
208
|
+
style={{ width: GUTTER_W }}
|
|
209
|
+
className={cn(
|
|
210
|
+
"flex shrink-0 items-center justify-center border-r border-border/60",
|
|
211
|
+
"cursor-pointer text-[10px] tabular-nums text-muted-foreground/60",
|
|
212
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
{rowIdx + 1}
|
|
216
|
+
</button>
|
|
217
|
+
{columns.map((c, ci) => {
|
|
218
|
+
const isSel = selected?.r === rowIdx && selected?.c === ci;
|
|
219
|
+
return (
|
|
220
|
+
<div
|
|
221
|
+
key={c.name}
|
|
222
|
+
data-selected={isSel}
|
|
223
|
+
onClick={(e) => {
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
setSelected({ r: rowIdx, c: ci });
|
|
226
|
+
setPopover(null);
|
|
227
|
+
}}
|
|
228
|
+
onDoubleClick={(e) => {
|
|
229
|
+
e.stopPropagation();
|
|
230
|
+
setSelected({ r: rowIdx, c: ci });
|
|
231
|
+
const editable =
|
|
232
|
+
idColIndex >= 0 &&
|
|
233
|
+
!READONLY_FIELDS.has(c.name) &&
|
|
234
|
+
!READONLY_TYPES.has(c.type);
|
|
235
|
+
setPopover({
|
|
236
|
+
r: rowIdx,
|
|
237
|
+
c: ci,
|
|
238
|
+
colName: c.name,
|
|
239
|
+
value: row[ci] ?? "",
|
|
240
|
+
rect: (e.currentTarget as HTMLElement).getBoundingClientRect(),
|
|
241
|
+
cart,
|
|
242
|
+
entity,
|
|
243
|
+
rowId: idColIndex >= 0 ? row[idColIndex] ?? "" : "",
|
|
244
|
+
colType: c.type,
|
|
245
|
+
editable,
|
|
246
|
+
});
|
|
247
|
+
}}
|
|
248
|
+
style={{ width: colWidth(c.name) }}
|
|
249
|
+
className={cn(
|
|
250
|
+
"flex cursor-pointer items-center overflow-hidden border-r border-border/60 px-2",
|
|
251
|
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
|
|
252
|
+
"data-[selected=true]:[box-shadow:inset_0_0_0_1px_#60a5fa]",
|
|
253
|
+
)}
|
|
254
|
+
title={row[ci]}
|
|
255
|
+
>
|
|
256
|
+
<span className="truncate">{row[ci]}</span>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
})}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
{popover ? <ValuePopover data={popover} onClose={() => setPopover(null)} /> : null}
|
|
268
|
+
{/* tick is bumped on each data chunk to force a re-render of the rows. */}
|
|
269
|
+
{tick < 0 && <span />}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export default DataTable;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// DataTable streams one entity off the shared bulk socket — mount inside a
|
|
2
|
+
// <BulkStreamProvider registry={...}> (see core).
|
|
3
|
+
export { default as DataTable } from "./data-table";
|
|
4
|
+
export { default as ValuePopover } from "./value-popover";
|
|
5
|
+
export type { ValuePopoverData } from "./value-popover";
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { cn } from "../core/utils";
|
|
4
|
+
import { useDevToolsConfig } from "../core/config";
|
|
5
|
+
import {
|
|
6
|
+
TYPE_U8,
|
|
7
|
+
TYPE_U16,
|
|
8
|
+
TYPE_U32,
|
|
9
|
+
TYPE_U64,
|
|
10
|
+
TYPE_S8,
|
|
11
|
+
TYPE_S16,
|
|
12
|
+
TYPE_S32,
|
|
13
|
+
TYPE_S64,
|
|
14
|
+
TYPE_BOOL,
|
|
15
|
+
TYPE_FLOAT32,
|
|
16
|
+
TYPE_FLOAT64,
|
|
17
|
+
} from "../generated/bulk-protocol";
|
|
18
|
+
|
|
19
|
+
const WIDTH = 420;
|
|
20
|
+
|
|
21
|
+
export type ValuePopoverData = {
|
|
22
|
+
r: number;
|
|
23
|
+
c: number;
|
|
24
|
+
colName: string;
|
|
25
|
+
value: string;
|
|
26
|
+
rect: DOMRect;
|
|
27
|
+
cart: string;
|
|
28
|
+
entity: string;
|
|
29
|
+
rowId: string;
|
|
30
|
+
colType: number;
|
|
31
|
+
editable: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const INT_TYPES = new Set([
|
|
35
|
+
TYPE_U8, TYPE_U16, TYPE_U32, TYPE_U64,
|
|
36
|
+
TYPE_S8, TYPE_S16, TYPE_S32, TYPE_S64,
|
|
37
|
+
]);
|
|
38
|
+
const FLOAT_TYPES = new Set([TYPE_FLOAT32, TYPE_FLOAT64]);
|
|
39
|
+
|
|
40
|
+
function position(rect: DOMRect): React.CSSProperties {
|
|
41
|
+
const margin = 12;
|
|
42
|
+
const left = Math.max(
|
|
43
|
+
margin,
|
|
44
|
+
Math.min(rect.left, window.innerWidth - WIDTH - margin),
|
|
45
|
+
);
|
|
46
|
+
const roomBelow = window.innerHeight - rect.bottom;
|
|
47
|
+
const top = roomBelow >= 180 ? rect.bottom + 6 : Math.max(margin, rect.top - 188);
|
|
48
|
+
return { left, top, width: WIDTH };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Coerce the edited string to the JSON value the update handler expects for the
|
|
52
|
+
// column's wire type. Returns the value or an error string.
|
|
53
|
+
function coerce(draft: string, colType: number): { value: unknown } | { error: string } {
|
|
54
|
+
if (colType === TYPE_BOOL) return { value: draft === "true" };
|
|
55
|
+
if (INT_TYPES.has(colType)) {
|
|
56
|
+
const n = Number(draft);
|
|
57
|
+
if (!Number.isInteger(n)) return { error: "Expected an integer" };
|
|
58
|
+
return { value: n };
|
|
59
|
+
}
|
|
60
|
+
if (FLOAT_TYPES.has(colType)) {
|
|
61
|
+
const n = Number(draft);
|
|
62
|
+
if (!Number.isFinite(n)) return { error: "Expected a number" };
|
|
63
|
+
return { value: n };
|
|
64
|
+
}
|
|
65
|
+
return { value: draft };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ValuePopover: React.FC<{ data: ValuePopoverData; onClose: () => void }> = ({
|
|
69
|
+
data,
|
|
70
|
+
onClose,
|
|
71
|
+
}) => {
|
|
72
|
+
const { apiBaseUrl } = useDevToolsConfig();
|
|
73
|
+
const [copied, setCopied] = React.useState(false);
|
|
74
|
+
const [draft, setDraft] = React.useState(data.value);
|
|
75
|
+
const [saving, setSaving] = React.useState(false);
|
|
76
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
77
|
+
|
|
78
|
+
React.useEffect(() => {
|
|
79
|
+
setDraft(data.value);
|
|
80
|
+
setError(null);
|
|
81
|
+
}, [data.value, data.r, data.c]);
|
|
82
|
+
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
const onKey = (e: KeyboardEvent) => {
|
|
85
|
+
if (e.key === "Escape") onClose();
|
|
86
|
+
};
|
|
87
|
+
document.addEventListener("keydown", onKey);
|
|
88
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
89
|
+
}, [onClose]);
|
|
90
|
+
|
|
91
|
+
const copy = async () => {
|
|
92
|
+
try {
|
|
93
|
+
await navigator.clipboard.writeText(data.value);
|
|
94
|
+
setCopied(true);
|
|
95
|
+
} catch {
|
|
96
|
+
/* clipboard unavailable */
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const save = async () => {
|
|
101
|
+
const result = coerce(draft, data.colType);
|
|
102
|
+
if ("error" in result) {
|
|
103
|
+
setError(result.error);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
setSaving(true);
|
|
107
|
+
setError(null);
|
|
108
|
+
try {
|
|
109
|
+
const r = await fetch(
|
|
110
|
+
`${apiBaseUrl}/api/${data.cart}/${data.entity.toLowerCase()}/${encodeURIComponent(data.rowId)}`,
|
|
111
|
+
{
|
|
112
|
+
method: "PUT",
|
|
113
|
+
credentials: "include",
|
|
114
|
+
headers: { "content-type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ [data.colName]: result.value }),
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
if (!r.ok) {
|
|
119
|
+
setError((await r.text()) || `HTTP ${r.status}`);
|
|
120
|
+
setSaving(false);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Success: the update handler broadcasts FRAME_DELTA_UPD, which refreshes
|
|
124
|
+
// the row in place — just close.
|
|
125
|
+
onClose();
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
128
|
+
setSaving(false);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const isBool = data.colType === TYPE_BOOL;
|
|
133
|
+
const isNumeric = INT_TYPES.has(data.colType) || FLOAT_TYPES.has(data.colType);
|
|
134
|
+
|
|
135
|
+
return createPortal(
|
|
136
|
+
<>
|
|
137
|
+
<div className="fixed inset-0 z-[60]" onClick={onClose} />
|
|
138
|
+
<div
|
|
139
|
+
className={cn(
|
|
140
|
+
"nimbit-devtools dark fixed z-[61] rounded-md border border-border bg-popover p-3",
|
|
141
|
+
"text-[12px] text-popover-foreground shadow-lg",
|
|
142
|
+
)}
|
|
143
|
+
style={position(data.rect)}
|
|
144
|
+
onClick={(e) => e.stopPropagation()}
|
|
145
|
+
>
|
|
146
|
+
<div className="mb-2 flex items-center justify-between gap-3">
|
|
147
|
+
<div className="min-w-0">
|
|
148
|
+
<div className="truncate font-medium text-foreground">{data.colName}</div>
|
|
149
|
+
<div className="text-[11px] text-muted-foreground">Row {data.r + 1}</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="flex shrink-0 items-center gap-1.5">
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
onClick={copy}
|
|
155
|
+
className={cn(
|
|
156
|
+
"rounded border border-border px-2 py-1 text-[11px] leading-none",
|
|
157
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
{copied ? "Copied" : "Copy"}
|
|
161
|
+
</button>
|
|
162
|
+
{data.editable ? (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={save}
|
|
166
|
+
disabled={saving}
|
|
167
|
+
className={cn(
|
|
168
|
+
"rounded border border-border bg-primary/90 px-2 py-1 text-[11px] leading-none text-primary-foreground",
|
|
169
|
+
"hover:bg-primary disabled:opacity-50",
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
{saving ? "Saving…" : "Save"}
|
|
173
|
+
</button>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{data.editable ? (
|
|
179
|
+
<>
|
|
180
|
+
{isBool ? (
|
|
181
|
+
<select
|
|
182
|
+
value={draft}
|
|
183
|
+
disabled={saving}
|
|
184
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
185
|
+
className="w-full rounded-sm border border-border bg-background px-2 py-1 text-[12px] outline-none focus:border-accent-foreground/30"
|
|
186
|
+
>
|
|
187
|
+
<option value="true">true</option>
|
|
188
|
+
<option value="false">false</option>
|
|
189
|
+
</select>
|
|
190
|
+
) : isNumeric ? (
|
|
191
|
+
<input
|
|
192
|
+
type="number"
|
|
193
|
+
value={draft}
|
|
194
|
+
disabled={saving}
|
|
195
|
+
autoFocus
|
|
196
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
197
|
+
onKeyDown={(e) => {
|
|
198
|
+
if (e.key === "Enter") void save();
|
|
199
|
+
}}
|
|
200
|
+
className="w-full rounded-sm border border-border bg-background px-2 py-1 font-mono text-[12px] outline-none focus:border-accent-foreground/30"
|
|
201
|
+
/>
|
|
202
|
+
) : (
|
|
203
|
+
<textarea
|
|
204
|
+
value={draft}
|
|
205
|
+
disabled={saving}
|
|
206
|
+
autoFocus
|
|
207
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
208
|
+
className="max-h-48 min-h-[3rem] w-full resize-y overflow-auto rounded-sm border border-border bg-background p-2 font-mono text-[11px] leading-5 outline-none focus:border-accent-foreground/30"
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
{error ? (
|
|
212
|
+
<div className="mt-2 break-words text-[11px] text-red-400">{error}</div>
|
|
213
|
+
) : null}
|
|
214
|
+
</>
|
|
215
|
+
) : (
|
|
216
|
+
<div className="max-h-48 overflow-auto rounded-sm bg-muted/40 p-2 font-mono text-[11px] leading-5 whitespace-pre-wrap break-words">
|
|
217
|
+
{data.value.length > 0 ? (
|
|
218
|
+
data.value
|
|
219
|
+
) : (
|
|
220
|
+
<span className="font-sans italic text-muted-foreground">(empty)</span>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
</>,
|
|
226
|
+
document.body,
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export default ValuePopover;
|