@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.
Files changed (66) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +10 -0
  3. package/TRADEMARKS.md +49 -0
  4. package/dist/chunk-3ZM6YOA4.js +704 -0
  5. package/dist/chunk-3ZM6YOA4.js.map +7 -0
  6. package/dist/chunk-7B2T5ZNG.js +467 -0
  7. package/dist/chunk-7B2T5ZNG.js.map +7 -0
  8. package/dist/chunk-S7VBQE6Y.js +636 -0
  9. package/dist/chunk-S7VBQE6Y.js.map +7 -0
  10. package/dist/chunk-UPEOXMLZ.js +625 -0
  11. package/dist/chunk-UPEOXMLZ.js.map +7 -0
  12. package/dist/core/auth.d.ts +13 -0
  13. package/dist/core/bulk-decoder.d.ts +13 -0
  14. package/dist/core/config.d.ts +10 -0
  15. package/dist/core/data-store.d.ts +20 -0
  16. package/dist/core/index.d.ts +9 -0
  17. package/dist/core/use-bulk-stream.d.ts +24 -0
  18. package/dist/core/use-cartridge-info.d.ts +14 -0
  19. package/dist/core/utils.d.ts +2 -0
  20. package/dist/editor/index.d.ts +7 -0
  21. package/dist/editor/index.js +16 -0
  22. package/dist/editor/index.js.map +7 -0
  23. package/dist/editor/lsp-client.d.ts +57 -0
  24. package/dist/editor/lsp-extensions.d.ts +4 -0
  25. package/dist/editor/nbt-editor.d.ts +13 -0
  26. package/dist/editor/nbt-language.d.ts +7 -0
  27. package/dist/generated/bulk-protocol.d.ts +36 -0
  28. package/dist/graph/diagram.d.ts +5 -0
  29. package/dist/graph/entity-graph-utils.d.ts +92 -0
  30. package/dist/graph/entity-node.d.ts +9 -0
  31. package/dist/graph/index.d.ts +5 -0
  32. package/dist/graph/index.js +19 -0
  33. package/dist/graph/index.js.map +7 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +134 -0
  36. package/dist/index.js.map +7 -0
  37. package/dist/styles.css +2 -0
  38. package/dist/table/data-table.d.ts +9 -0
  39. package/dist/table/index.d.ts +3 -0
  40. package/dist/table/index.js +11 -0
  41. package/dist/table/index.js.map +7 -0
  42. package/dist/table/value-popover.d.ts +18 -0
  43. package/package.json +77 -0
  44. package/src/core/auth.ts +100 -0
  45. package/src/core/bulk-decoder.ts +178 -0
  46. package/src/core/config.tsx +39 -0
  47. package/src/core/data-store.ts +113 -0
  48. package/src/core/index.ts +34 -0
  49. package/src/core/use-bulk-stream.ts +412 -0
  50. package/src/core/use-cartridge-info.ts +100 -0
  51. package/src/core/utils.ts +6 -0
  52. package/src/editor/index.ts +13 -0
  53. package/src/editor/lsp-client.ts +227 -0
  54. package/src/editor/lsp-extensions.ts +191 -0
  55. package/src/editor/nbt-editor.tsx +142 -0
  56. package/src/editor/nbt-language.ts +151 -0
  57. package/src/generated/bulk-protocol.ts +63 -0
  58. package/src/graph/diagram.tsx +296 -0
  59. package/src/graph/entity-graph-utils.ts +423 -0
  60. package/src/graph/entity-node.tsx +122 -0
  61. package/src/graph/index.ts +19 -0
  62. package/src/index.ts +7 -0
  63. package/src/styles.css +94 -0
  64. package/src/table/data-table.tsx +274 -0
  65. package/src/table/index.ts +5 -0
  66. 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;