4bnode 3.5.3 → 4.0.2

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 (62) hide show
  1. package/README.md +144 -109
  2. package/add-api.js +288 -156
  3. package/add-crud.js +226 -0
  4. package/add-login.js +152 -319
  5. package/add-mongo-delete.js +67 -138
  6. package/add-mongo-insert.js +87 -204
  7. package/add-mongo-read.js +100 -216
  8. package/add-mongo-schema.js +431 -199
  9. package/add-mongo-update.js +89 -202
  10. package/add-mongo.js +125 -218
  11. package/add-serialport.js +53 -98
  12. package/add-socket.js +43 -66
  13. package/add-websocket.js +68 -140
  14. package/index.js +341 -156
  15. package/lib/env.js +18 -0
  16. package/lib/indexFile.js +134 -0
  17. package/lib/names.js +21 -0
  18. package/lib/project.js +122 -0
  19. package/lib/routeFile.js +56 -0
  20. package/lib/ui.js +161 -0
  21. package/package.json +9 -16
  22. package/skeleton/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  23. package/skeleton/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  24. package/skeleton/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  25. package/skeleton/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  26. package/skeleton/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  27. package/skeleton/.4bnode/assets/images/mongo.png +0 -0
  28. package/skeleton/.4bnode/assets/images/mysql.png +0 -0
  29. package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
  30. package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
  31. package/skeleton/.4bnode/assets/js/App.js +115 -0
  32. package/skeleton/.4bnode/assets/js/Root.js +44 -0
  33. package/skeleton/.4bnode/assets/js/components/Modal.js +52 -0
  34. package/skeleton/.4bnode/assets/js/components/Sidebar.js +68 -0
  35. package/skeleton/.4bnode/assets/js/components/ToastContainer.js +15 -0
  36. package/skeleton/.4bnode/assets/js/context.js +7 -0
  37. package/skeleton/.4bnode/assets/js/lib.js +83 -0
  38. package/skeleton/.4bnode/assets/js/main.js +5 -0
  39. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +1594 -0
  40. package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +435 -0
  41. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +461 -0
  42. package/skeleton/.4bnode/assets/js/pages/EnvironmentPage.js +141 -0
  43. package/skeleton/.4bnode/assets/js/pages/RealtimePage.js +186 -0
  44. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1656 -0
  45. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +901 -0
  46. package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
  47. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
  48. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +66 -0
  49. package/skeleton/.4bnode/dev-api.js +2930 -0
  50. package/skeleton/.4bnode/ui.html +1556 -0
  51. package/skeleton/index.js +70 -17
  52. package/skeleton/package.json +9 -5
  53. package/skeleton/public/index.html +288 -86
  54. package/README_4bnode.md +0 -197
  55. package/src/auth/passport.js +0 -21
  56. package/src/db.js +0 -13
  57. package/test-server/.env.development +0 -1
  58. package/test-server/.env.production +0 -1
  59. package/test-server/index.js +0 -82
  60. package/test-server/package-lock.json +0 -1267
  61. package/test-server/package.json +0 -20
  62. package/test-server/public/index.html +0 -90
@@ -0,0 +1,901 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ import { LuFileCode, LuLock, LuFileText, LuTable, LuDownload, LuEye, LuEyeOff, LuChevronLeft, LuChevronRight } from "react-icons/lu";
4
+ import { html, api, Icons } from "../lib.js";
5
+ import { useApp } from "../context.js";
6
+
7
+ export function SchemasPage() {
8
+ const { data, toast, refresh, setPage, showConfirm } = useApp();
9
+ const [showCreate, setShowCreate] = useState(false);
10
+ const [editModel, setEditModel] = useState(null);
11
+ const [selectedModel, setSelectedModel] = useState(null);
12
+
13
+ // Auto-select first model
14
+ useEffect(() => {
15
+ if (
16
+ data.models?.length > 0 &&
17
+ !selectedModel &&
18
+ !showCreate &&
19
+ !editModel
20
+ ) {
21
+ setSelectedModel(data.models[0].name);
22
+ }
23
+ }, [data.models]);
24
+
25
+ if (!data.mongoConfigured) {
26
+ return html`<div className="locked-state">
27
+ <div className="locked-icon"><${LuLock} size=${24} /></div>
28
+ <h3>Database Required</h3>
29
+ <p>
30
+ You need to set up a database before creating schemas. Schemas
31
+ define the structure of your data.
32
+ </p>
33
+ <button
34
+ className="btn btn-primary"
35
+ onClick=${() => setPage("database")}
36
+ >
37
+ Set Up Database
38
+ </button>
39
+ </div>`;
40
+ }
41
+
42
+ const selected = data.models?.find((m) => m.name === selectedModel);
43
+ const reqCount =
44
+ selected?.fields?.filter((f) => f.required).length || 0;
45
+ const uniqCount = selected?.fields?.filter((f) => f.unique).length || 0;
46
+ const isEditing = showCreate || !!editModel;
47
+
48
+ // Empty state + no create open
49
+ if (data.models?.length === 0 && !showCreate) {
50
+ return html`<div>
51
+ <div className="flex items-center justify-between mb-4">
52
+ <div className="card-subtitle">0 models defined</div>
53
+ <button
54
+ className="btn btn-primary"
55
+ onClick=${() => {
56
+ setShowCreate(true);
57
+ setSelectedModel(null);
58
+ setEditModel(null);
59
+ }}
60
+ >
61
+ <span className="sidebar-icon">${Icons.plus}</span> New Schema
62
+ </button>
63
+ </div>
64
+ <div className="empty-state">
65
+ <div className="empty-icon"><${LuFileCode} size=${36} /></div>
66
+ <h3>No schemas yet</h3>
67
+ <p>
68
+ Create your first model schema to define your data structure
69
+ </p>
70
+ <button
71
+ className="btn btn-primary"
72
+ onClick=${() => setShowCreate(true)}
73
+ >
74
+ Create Schema
75
+ </button>
76
+ </div>
77
+ </div>`;
78
+ }
79
+
80
+ return html`<div>
81
+ <div className="flex items-center justify-between mb-4">
82
+ <div className="card-subtitle">
83
+ ${data.models?.length || 0}
84
+ model${data.models?.length !== 1 ? "s" : ""} defined
85
+ </div>
86
+ <button
87
+ className=${`btn ${isEditing ? "btn-ghost" : "btn-primary"}`}
88
+ onClick=${() => {
89
+ if (isEditing) {
90
+ setShowCreate(false);
91
+ setEditModel(null);
92
+ if (data.models?.length)
93
+ setSelectedModel(data.models[0].name);
94
+ } else {
95
+ setShowCreate(true);
96
+ setSelectedModel(null);
97
+ setEditModel(null);
98
+ }
99
+ }}
100
+ >
101
+ ${isEditing
102
+ ? "Cancel"
103
+ : html`<span className="sidebar-icon">${Icons.plus}</span> New
104
+ Schema`}
105
+ </button>
106
+ </div>
107
+
108
+ <div className="routes-layout">
109
+ <div className="card" style=${{ padding: 0, overflow: "hidden" }}>
110
+ <div
111
+ style=${{
112
+ padding: "14px 16px 10px",
113
+ borderBottom: "1px solid var(--border)",
114
+ }}
115
+ >
116
+ <span
117
+ style=${{
118
+ fontWeight: 600,
119
+ fontSize: "12px",
120
+ textTransform: "uppercase",
121
+ letterSpacing: "0.5px",
122
+ color: "var(--text-muted)",
123
+ }}
124
+ >Models</span
125
+ >
126
+ </div>
127
+ <div className="routes-list">
128
+ ${(data.models || []).map((m) => {
129
+ const isActive =
130
+ selectedModel === m.name && !showCreate && !editModel;
131
+ const isEditActive = editModel?.name === m.name;
132
+ return html`<div
133
+ key=${m.name}
134
+ className=${`route-item ${isActive || isEditActive ? "active" : ""}`}
135
+ onClick=${() => {
136
+ setShowCreate(false);
137
+ setEditModel(null);
138
+ setSelectedModel(m.name);
139
+ }}
140
+ >
141
+ <div
142
+ style=${{
143
+ display: "flex",
144
+ flexDirection: "column",
145
+ gap: "4px",
146
+ flex: 1,
147
+ minWidth: 0,
148
+ }}
149
+ >
150
+ <span className="ri-name">${m.name}</span>
151
+ <span className="ri-file">src/models/${m.name}.js</span>
152
+ </div>
153
+ <span className="field-count"
154
+ >${m.fields?.length || 0}</span
155
+ >
156
+ </div>`;
157
+ })}
158
+ <div
159
+ className=${`route-item ${showCreate ? "active" : ""}`}
160
+ onClick=${() => {
161
+ setShowCreate(true);
162
+ setSelectedModel(null);
163
+ setEditModel(null);
164
+ }}
165
+ style=${{
166
+ borderTop: data.models?.length
167
+ ? "1px solid var(--border)"
168
+ : "none",
169
+ }}
170
+ >
171
+ <span
172
+ className="sidebar-icon"
173
+ style=${{ color: "var(--accent-light)" }}
174
+ >${Icons.plus}</span
175
+ >
176
+ <span
177
+ className="ri-name"
178
+ style=${{ color: "var(--accent-light)" }}
179
+ >New Schema</span
180
+ >
181
+ </div>
182
+ </div>
183
+ </div>
184
+
185
+ <div className="routes-detail">
186
+ ${showCreate &&
187
+ html`<${SchemaBuilderPanel}
188
+ onDone=${() => {
189
+ setShowCreate(false);
190
+ refresh();
191
+ }}
192
+ />`}
193
+ ${editModel &&
194
+ !showCreate &&
195
+ html`<${SchemaBuilderPanel}
196
+ model=${editModel}
197
+ onDone=${() => {
198
+ setEditModel(null);
199
+ refresh();
200
+ }}
201
+ />`}
202
+ ${!showCreate &&
203
+ !editModel &&
204
+ !selected &&
205
+ html`<div className="card">
206
+ <div className="empty-state" style=${{ padding: "40px" }}>
207
+ <p>Select a model to view its fields</p>
208
+ </div>
209
+ </div>`}
210
+ ${!showCreate &&
211
+ !editModel &&
212
+ selected &&
213
+ html`<${SchemaDetailPanel} model=${selected} reqCount=${reqCount} uniqCount=${uniqCount}
214
+ onEdit=${() => { setEditModel(selected); setSelectedModel(null); }}
215
+ onDelete=${async () => {
216
+ const ok = await showConfirm('Are you sure you want to delete the model "' + selected.name + '"? This will remove the schema file.', { danger: true, confirmLabel: 'Delete Model' });
217
+ if (!ok) return;
218
+ try { await api("/models/" + selected.name, { method: "DELETE" }); toast('Model "' + selected.name + '" deleted', "success"); setSelectedModel(null); refresh(); }
219
+ catch (err) { toast(err.message, "error"); }
220
+ }}
221
+ />`}
222
+ </div>
223
+ </div>
224
+ </div>`;
225
+ }
226
+
227
+ function SchemaDetailPanel({ model, reqCount, uniqCount, onEdit, onDelete }) {
228
+ const { toast, data } = useApp();
229
+ const [tab, setTab] = useState('fields');
230
+ // Data tab state
231
+ const [docs, setDocs] = useState([]);
232
+ const [dataTotal, setDataTotal] = useState(0);
233
+ const [dataPage, setDataPage] = useState(0);
234
+ const [dataLoading, setDataLoading] = useState(false);
235
+ const [visibleCols, setVisibleCols] = useState(null);
236
+ const [showColPicker, setShowColPicker] = useState(false);
237
+ const [showDownloadMenu, setShowDownloadMenu] = useState(false);
238
+ const [downloadingAll, setDownloadingAll] = useState(false);
239
+
240
+ // Close download menu on outside click
241
+ useEffect(() => {
242
+ if (!showDownloadMenu) return;
243
+ const close = () => setShowDownloadMenu(false);
244
+ setTimeout(() => document.addEventListener('click', close), 0);
245
+ return () => document.removeEventListener('click', close);
246
+ }, [showDownloadMenu]);
247
+
248
+ const allFields = ['_id', ...(model.fields?.map(f => f.name) || []), 'createdAt', 'updatedAt'];
249
+ const cols = visibleCols || allFields.slice(0, 6);
250
+ const pageSize = 20;
251
+
252
+ async function loadData(page) {
253
+ setDataLoading(true);
254
+ try {
255
+ const res = await api('/models/' + model.name + '/data?limit=' + pageSize + '&skip=' + (page * pageSize));
256
+ setDocs(res.docs || []);
257
+ setDataTotal(res.total || 0);
258
+ setDataPage(page);
259
+ } catch (err) { toast(err.message, 'error'); }
260
+ setDataLoading(false);
261
+ }
262
+
263
+ useEffect(() => {
264
+ if (tab === 'data' && docs.length === 0) loadData(0);
265
+ }, [tab]);
266
+
267
+ // Reset when model changes
268
+ useEffect(() => {
269
+ setDocs([]); setDataTotal(0); setDataPage(0); setVisibleCols(null); setShowDownloadMenu(false); setTab('fields');
270
+ }, [model.name]);
271
+
272
+ function buildCSV(data, columns) {
273
+ const header = columns.join(',');
274
+ const rows = data.map(d => columns.map(c => {
275
+ const val = d[c];
276
+ if (val === null || val === undefined) return '';
277
+ const str = typeof val === 'object' ? JSON.stringify(val) : String(val);
278
+ return str.includes(',') || str.includes('"') || str.includes('\n') ? '"' + str.replace(/"/g, '""') + '"' : str;
279
+ }).join(','));
280
+ return header + '\n' + rows.join('\n');
281
+ }
282
+
283
+ function triggerDownload(csv, filename) {
284
+ const blob = new Blob([csv], { type: 'text/csv' });
285
+ const url = URL.createObjectURL(blob);
286
+ const a = document.createElement('a');
287
+ a.href = url; a.download = filename; a.click();
288
+ URL.revokeObjectURL(url);
289
+ }
290
+
291
+ function downloadCurrentPage() {
292
+ if (!docs.length) return;
293
+ const csv = buildCSV(docs, cols);
294
+ triggerDownload(csv, model.name + '_page' + (dataPage + 1) + '.csv');
295
+ toast('Current page downloaded (' + docs.length + ' rows)', 'success');
296
+ setShowDownloadMenu(false);
297
+ }
298
+
299
+ async function downloadAllData() {
300
+ setDownloadingAll(true);
301
+ setShowDownloadMenu(false);
302
+ try {
303
+ const res = await api('/models/' + model.name + '/data?limit=0');
304
+ const allDocs = res.docs || [];
305
+ if (allDocs.length === 0) {
306
+ toast('No data to download', 'error');
307
+ setDownloadingAll(false);
308
+ return;
309
+ }
310
+ const csv = buildCSV(allDocs, cols);
311
+ triggerDownload(csv, model.name + '_all.csv');
312
+ toast('All data downloaded (' + allDocs.length + ' rows)', 'success');
313
+ } catch (err) {
314
+ toast(err.message, 'error');
315
+ }
316
+ setDownloadingAll(false);
317
+ }
318
+
319
+ const thStyle = { padding: '10px 16px', fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', textAlign: 'left', borderBottom: '1px solid var(--border)' };
320
+ const tdStyle = { padding: '8px 16px', fontSize: '12px', borderBottom: '1px solid var(--border)' };
321
+
322
+ const tabs = [
323
+ { id: 'fields', label: 'Fields', icon: LuFileText },
324
+ { id: 'data', label: 'Data', icon: LuTable },
325
+ ];
326
+
327
+ return html`<div>
328
+ <div className="card mb-4">
329
+ <div className="flex items-center justify-between mb-3">
330
+ <div>
331
+ <div style=${{ fontWeight: 600, fontSize: '16px', marginBottom: '2px' }}>${model.name}</div>
332
+ <div className="text-sm text-muted" style=${{ fontFamily: "'SF Mono', monospace" }}>src/models/${model.name}.js</div>
333
+ </div>
334
+ <div className="flex gap-2">
335
+ <button className="btn btn-ghost btn-sm" onClick=${onEdit}>
336
+ <span className="sidebar-icon">${Icons.edit}</span> Edit
337
+ </button>
338
+ <button className="btn btn-danger btn-sm btn-icon" onClick=${onDelete}>
339
+ <span className="sidebar-icon">${Icons.trash}</span>
340
+ </button>
341
+ </div>
342
+ </div>
343
+ <div className="flex gap-3">
344
+ ${[['Fields', model.fields?.length || 0, null], ['Required', reqCount, reqCount > 0 ? 'var(--success)' : null], ['Unique', uniqCount, uniqCount > 0 ? 'var(--warning)' : null]].map(([label, count, color]) =>
345
+ html`<div key=${label} style=${{ padding: '8px 14px', background: 'var(--bg-input)', borderRadius: 'var(--radius)', fontSize: '13px' }}>
346
+ <span style=${{ color: 'var(--text-muted)' }}>${label} </span>
347
+ <span style=${{ fontWeight: 600, color: color || 'var(--text-secondary)' }}>${count}</span>
348
+ </div>`
349
+ )}
350
+ </div>
351
+ </div>
352
+
353
+ <div style=${{ display: 'inline-flex', gap: '2px', padding: '3px', background: 'var(--bg-input)', borderRadius: '8px', marginBottom: '16px' }}>
354
+ ${tabs.map(t => html`<button key=${t.id} onClick=${() => setTab(t.id)}
355
+ style=${{ padding: '6px 14px', borderRadius: '6px', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px',
356
+ background: tab === t.id ? 'var(--bg-surface)' : 'transparent', color: tab === t.id ? 'var(--text-primary)' : 'var(--text-muted)',
357
+ fontSize: '13px', fontWeight: tab === t.id ? 600 : 400, fontFamily: 'inherit',
358
+ boxShadow: tab === t.id ? '0 1px 3px rgba(0,0,0,0.2)' : 'none', transition: 'all 0.15s' }}>
359
+ <${t.icon} size=${13} /> ${t.label}
360
+ </button>`)}
361
+ </div>
362
+
363
+ <${AnimatePresence} mode="wait">
364
+ <${motion.div} key=${tab} initial=${{ opacity: 0, y: 6 }} animate=${{ opacity: 1, y: 0 }} exit=${{ opacity: 0, y: -6 }} transition=${{ duration: 0.12 }}>
365
+
366
+ ${tab === 'fields' && html`<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
367
+ <div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
368
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Schema Fields</span>
369
+ </div>
370
+ <table style=${{ width: '100%' }}>
371
+ <thead><tr>
372
+ <th style=${thStyle}>#</th><th style=${thStyle}>Name</th><th style=${thStyle}>Type</th><th style=${thStyle}>Properties</th>
373
+ </tr></thead>
374
+ <tbody>
375
+ ${model.fields?.map((f, i) => html`<tr key=${f.name} style=${{ borderBottom: '1px solid var(--border)' }}>
376
+ <td style=${{ ...tdStyle, color: 'var(--text-muted)' }}>${i + 1}</td>
377
+ <td style=${{ ...tdStyle, fontWeight: 500, fontSize: '13px', fontFamily: "'SF Mono', monospace" }}>${f.name}</td>
378
+ <td style=${tdStyle}><span style=${{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: 'var(--accent-glow)', color: 'var(--accent-light)' }}>${f.type || 'String'}</span></td>
379
+ <td style=${tdStyle}><div className="flex gap-1">
380
+ ${f.required && html`<span className="badge badge-success" style=${{ fontSize: '10px' }}>required</span>`}
381
+ ${f.unique && html`<span className="badge badge-warning" style=${{ fontSize: '10px' }}>unique</span>`}
382
+ ${!f.required && !f.unique && html`<span style=${{ fontSize: '12px', color: 'var(--text-muted)' }}>—</span>`}
383
+ </div></td>
384
+ </tr>`)}
385
+ </tbody>
386
+ </table>
387
+ </div>`}
388
+
389
+ ${tab === 'data' && html`<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
390
+ <div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
391
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', flex: 1 }}>
392
+ Collection Data${dataTotal > 0 ? ' (' + dataTotal + ' documents)' : ''}
393
+ </span>
394
+ <button className="btn btn-sm btn-ghost" onClick=${() => setShowColPicker(!showColPicker)} title="Select columns">
395
+ <${showColPicker ? LuEyeOff : LuEye} size=${14} />
396
+ </button>
397
+ <div style=${{ position: 'relative' }}>
398
+ <button className="btn btn-sm btn-ghost" onClick=${() => setShowDownloadMenu(!showDownloadMenu)} disabled=${!docs.length && !dataTotal} title="Download CSV">
399
+ ${downloadingAll ? html`<span className="spinner" style=${{ width: '14px', height: '14px' }} />` : html`<${LuDownload} size=${14} />`}
400
+ </button>
401
+ ${showDownloadMenu && html`<div style=${{
402
+ position: 'absolute', top: '100%', right: 0, marginTop: '4px', zIndex: 10,
403
+ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: '8px',
404
+ boxShadow: '0 8px 24px rgba(0,0,0,0.3)', minWidth: '200px', overflow: 'hidden'
405
+ }}>
406
+ <button onClick=${downloadCurrentPage} disabled=${!docs.length}
407
+ style=${{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%', padding: '10px 14px',
408
+ background: 'transparent', border: 'none', color: !docs.length ? 'var(--text-muted)' : 'var(--text-secondary)',
409
+ fontSize: '13px', cursor: !docs.length ? 'not-allowed' : 'pointer', fontFamily: 'inherit', textAlign: 'left' }}
410
+ onMouseEnter=${e => { if (docs.length) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
411
+ onMouseLeave=${e => { e.currentTarget.style.background = 'transparent'; }}>
412
+ <${LuDownload} size=${13} /> Current page (${docs.length} rows)
413
+ </button>
414
+ <button onClick=${downloadAllData}
415
+ style=${{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%', padding: '10px 14px',
416
+ background: 'transparent', border: 'none', color: 'var(--text-secondary)',
417
+ fontSize: '13px', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
418
+ borderTop: '1px solid var(--border)' }}
419
+ onMouseEnter=${e => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
420
+ onMouseLeave=${e => { e.currentTarget.style.background = 'transparent'; }}>
421
+ <${LuDownload} size=${13} /> All data (${dataTotal} rows)
422
+ </button>
423
+ </div>`}
424
+ </div>
425
+ <button className="btn btn-sm btn-ghost" onClick=${() => loadData(dataPage)} title="Refresh">
426
+ <${LuTable} size=${14} />
427
+ </button>
428
+ </div>
429
+ ${showColPicker && html`<div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
430
+ ${allFields.map(f => html`<button key=${f}
431
+ onClick=${() => {
432
+ const next = cols.includes(f) ? cols.filter(c => c !== f) : [...cols, f];
433
+ if (next.length > 0) setVisibleCols(next);
434
+ }}
435
+ style=${{ padding: '3px 10px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'monospace',
436
+ background: cols.includes(f) ? 'var(--accent-glow)' : 'transparent', color: cols.includes(f) ? 'var(--accent-light)' : 'var(--text-muted)' }}>
437
+ ${f}
438
+ </button>`)}
439
+ </div>`}
440
+ ${dataLoading && html`<div style=${{ padding: '40px', textAlign: 'center' }}><span className="spinner" /></div>`}
441
+ ${!dataLoading && docs.length === 0 && html`<div style=${{ padding: '40px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>No documents found in this collection</div>`}
442
+ ${!dataLoading && docs.length > 0 && html`<div style=${{ overflowX: 'auto' }}>
443
+ <table style=${{ width: '100%', minWidth: cols.length * 140 + 'px' }}>
444
+ <thead><tr>${cols.map(c => html`<th key=${c} style=${thStyle}>${c}</th>`)}</tr></thead>
445
+ <tbody>
446
+ ${docs.map((doc, i) => html`<tr key=${i} style=${{ borderBottom: '1px solid var(--border)' }}>
447
+ ${cols.map(c => {
448
+ const val = doc[c];
449
+ let display = val === null || val === undefined ? '—' : typeof val === 'object' ? JSON.stringify(val) : String(val);
450
+ if (display.length > 60) display = display.slice(0, 57) + '...';
451
+ return html`<td key=${c} style=${{ ...tdStyle, fontFamily: c === '_id' ? 'monospace' : 'inherit', fontSize: '12px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${display}</td>`;
452
+ })}
453
+ </tr>`)}
454
+ </tbody>
455
+ </table>
456
+ </div>`}
457
+ ${dataTotal > pageSize && html`<div style=${{ padding: '10px 16px', borderTop: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '12px', color: 'var(--text-muted)' }}>
458
+ <span>Page ${dataPage + 1} of ${Math.ceil(dataTotal / pageSize)}</span>
459
+ <div className="flex gap-2">
460
+ <button className="btn btn-sm btn-ghost" disabled=${dataPage === 0} onClick=${() => loadData(dataPage - 1)}><${LuChevronLeft} size=${14} /></button>
461
+ <button className="btn btn-sm btn-ghost" disabled=${(dataPage + 1) * pageSize >= dataTotal} onClick=${() => loadData(dataPage + 1)}><${LuChevronRight} size=${14} /></button>
462
+ </div>
463
+ </div>`}
464
+ </div>`}
465
+
466
+ <//>
467
+ <//>
468
+ </div>`;
469
+ }
470
+
471
+ function SchemaBuilderPanel({ model, onDone }) {
472
+ const { data, toast, refresh } = useApp();
473
+ const [name, setName] = useState(model?.name || "");
474
+ const [fields, setFields] = useState(
475
+ model?.fields?.map((f) => ({ ...f })) || [],
476
+ );
477
+ const [loading, setLoading] = useState(false);
478
+ const [saved, setSaved] = useState(false);
479
+ const [editIndex, setEditIndex] = useState(null);
480
+ const isEdit = !!model;
481
+
482
+ // Add/edit field form state
483
+ const [fname, setFname] = useState("");
484
+ const [ftype, setFtype] = useState("String");
485
+ const [freq, setFreq] = useState(false);
486
+ const [funiq, setFuniq] = useState(false);
487
+ const [fref, setFref] = useState("");
488
+ const nameRef = useRef(null);
489
+ const fieldNameRef = useRef(null);
490
+
491
+ const fieldTypes = [
492
+ "String",
493
+ "Number",
494
+ "Boolean",
495
+ "Date",
496
+ "ObjectId",
497
+ "Array",
498
+ "Mixed",
499
+ "Buffer",
500
+ "Map",
501
+ ];
502
+
503
+ // Get other model names for ObjectId ref
504
+ const otherModels = (data.models || [])
505
+ .map((m) => m.name)
506
+ .filter((n) => n !== name);
507
+
508
+ useEffect(() => {
509
+ if (!isEdit && nameRef.current) nameRef.current.focus();
510
+ else if (isEdit && fieldNameRef.current) fieldNameRef.current.focus();
511
+ }, []);
512
+
513
+ function resetFieldForm() {
514
+ setFname("");
515
+ setFtype("String");
516
+ setFreq(false);
517
+ setFuniq(false);
518
+ setFref("");
519
+ setEditIndex(null);
520
+ }
521
+
522
+ function addOrUpdateField() {
523
+ if (!fname.trim()) return;
524
+ const fieldData = {
525
+ name: fname.trim(),
526
+ type: ftype,
527
+ required: freq,
528
+ unique: funiq,
529
+ };
530
+ if (ftype === "ObjectId" && fref) fieldData.ref = fref;
531
+
532
+ if (editIndex !== null) {
533
+ setFields((prev) =>
534
+ prev.map((f, i) => (i === editIndex ? fieldData : f)),
535
+ );
536
+ setEditIndex(null);
537
+ } else {
538
+ setFields((prev) => [...prev, fieldData]);
539
+ }
540
+ resetFieldForm();
541
+ setSaved(false);
542
+ setTimeout(() => fieldNameRef.current?.focus(), 50);
543
+ }
544
+
545
+ function startEdit(index) {
546
+ const f = fields[index];
547
+ setFname(f.name);
548
+ setFtype(f.type || "String");
549
+ setFreq(!!f.required);
550
+ setFuniq(!!f.unique);
551
+ setFref(f.ref || "");
552
+ setEditIndex(index);
553
+ setTimeout(() => fieldNameRef.current?.focus(), 50);
554
+ }
555
+
556
+ function removeField(index) {
557
+ setFields((prev) => prev.filter((_, i) => i !== index));
558
+ if (editIndex === index) resetFieldForm();
559
+ setSaved(false);
560
+ }
561
+
562
+ async function handleSave() {
563
+ if (!name.trim()) {
564
+ toast("Model name is required", "error");
565
+ return;
566
+ }
567
+ if (fields.length === 0) {
568
+ toast("Add at least one field", "error");
569
+ return;
570
+ }
571
+ setLoading(true);
572
+ try {
573
+ if (isEdit) {
574
+ await api("/models/" + model.name, {
575
+ method: "PUT",
576
+ body: { fields },
577
+ });
578
+ toast('Model "' + model.name + '" updated', "success");
579
+ } else {
580
+ await api("/models", {
581
+ method: "POST",
582
+ body: { name: name.trim(), fields },
583
+ });
584
+ toast('Model "' + name.trim() + '" created', "success");
585
+ }
586
+ setSaved(true);
587
+ refresh();
588
+ onDone();
589
+ } catch (err) {
590
+ toast(err.message, "error");
591
+ }
592
+ setLoading(false);
593
+ }
594
+
595
+ const typeColors = {
596
+ String: "var(--accent-light)",
597
+ Number: "var(--success)",
598
+ Boolean: "var(--warning)",
599
+ Date: "#a78bfa",
600
+ ObjectId: "#f472b6",
601
+ Array: "#22d3ee",
602
+ Mixed: "var(--text-secondary)",
603
+ Buffer: "#fb923c",
604
+ Map: "#34d399",
605
+ };
606
+
607
+ return html`<div>
608
+ <div className="card mb-4">
609
+ <div
610
+ className="flex items-center justify-between"
611
+ style=${{ marginBottom: "16px" }}
612
+ >
613
+ <div style=${{ fontSize: "15px", fontWeight: 600 }}>
614
+ ${isEdit ? "Edit Schema: " + model.name : "Create New Schema"}
615
+ </div>
616
+ <button
617
+ className="btn btn-primary btn-sm"
618
+ onClick=${handleSave}
619
+ disabled=${loading || fields.length === 0 || !name.trim()}
620
+ >
621
+ ${loading ? html`<span className="spinner" />` : null}
622
+ ${saved ? "\u2713 Saved" : isEdit ? "Update" : "Save Schema"}
623
+ </button>
624
+ </div>
625
+
626
+ ${!isEdit &&
627
+ html`<div className="form-group">
628
+ <label className="form-label">Model Name</label>
629
+ <input
630
+ ref=${nameRef}
631
+ className="form-input"
632
+ value=${name}
633
+ onInput=${(e) => setName(e.target.value)}
634
+ placeholder="e.g. user, product, post"
635
+ style=${{ fontSize: "14px", padding: "11px 14px" }}
636
+ />
637
+ </div>`}
638
+
639
+ <div
640
+ style=${{
641
+ fontSize: "11px",
642
+ fontWeight: 600,
643
+ textTransform: "uppercase",
644
+ letterSpacing: "0.5px",
645
+ color:
646
+ editIndex !== null ? "var(--warning)" : "var(--text-muted)",
647
+ marginBottom: "10px",
648
+ marginTop: isEdit ? 0 : "8px",
649
+ }}
650
+ >
651
+ ${editIndex !== null ? "Edit Field" : "Add Field"}
652
+ </div>
653
+
654
+ <div
655
+ className="flex items-center gap-3 mb-3"
656
+ style=${{ flexWrap: "wrap" }}
657
+ >
658
+ <input
659
+ ref=${fieldNameRef}
660
+ className="form-input"
661
+ value=${fname}
662
+ onInput=${(e) => setFname(e.target.value)}
663
+ onKeyDown=${(e) => e.key === "Enter" && addOrUpdateField()}
664
+ placeholder="Field name"
665
+ style=${{
666
+ fontSize: "13px",
667
+ flex: "1 1 120px",
668
+ minWidth: "100px",
669
+ }}
670
+ />
671
+ <select
672
+ className="form-select"
673
+ value=${ftype}
674
+ onChange=${(e) => {
675
+ setFtype(e.target.value);
676
+ if (e.target.value !== "ObjectId") setFref("");
677
+ }}
678
+ style=${{ fontSize: "13px", width: "110px", flex: "0 0 110px" }}
679
+ >
680
+ ${fieldTypes.map(
681
+ (t) => html`<option key=${t} value=${t}>${t}</option>`,
682
+ )}
683
+ </select>
684
+ ${ftype === "ObjectId" &&
685
+ html`<select
686
+ className="form-select"
687
+ value=${fref}
688
+ onChange=${(e) => setFref(e.target.value)}
689
+ style=${{ fontSize: "13px", width: "110px", flex: "0 0 110px" }}
690
+ >
691
+ <option value="">Ref...</option>
692
+ ${otherModels.map(
693
+ (m) => html`<option key=${m} value=${m}>${m}</option>`,
694
+ )}
695
+ </select>`}
696
+ <div style=${{ display: 'flex', alignItems: 'center', gap: '16px', padding: '0 4px' }}>
697
+ <label
698
+ className="flex items-center gap-2"
699
+ style=${{
700
+ cursor: "pointer",
701
+ fontSize: "12px",
702
+ whiteSpace: "nowrap",
703
+ color: freq ? "var(--success)" : "var(--text-muted)",
704
+ }}
705
+ >
706
+ <div className="form-toggle">
707
+ <input
708
+ type="checkbox"
709
+ checked=${freq}
710
+ onChange=${(e) => setFreq(e.target.checked)}
711
+ />
712
+ <span className="slider" />
713
+ </div>
714
+ Required
715
+ </label>
716
+ <label
717
+ className="flex items-center gap-2"
718
+ style=${{
719
+ cursor: "pointer",
720
+ fontSize: "12px",
721
+ whiteSpace: "nowrap",
722
+ color: funiq ? "var(--warning)" : "var(--text-muted)",
723
+ }}
724
+ >
725
+ <div className="form-toggle">
726
+ <input
727
+ type="checkbox"
728
+ checked=${funiq}
729
+ onChange=${(e) => setFuniq(e.target.checked)}
730
+ />
731
+ <span className="slider" />
732
+ </div>
733
+ Unique
734
+ </label>
735
+ </div>
736
+ ${editIndex !== null &&
737
+ html`<button
738
+ className="btn btn-ghost btn-sm"
739
+ onClick=${resetFieldForm}
740
+ style=${{ padding: "5px 8px" }}
741
+ >
742
+ Cancel
743
+ </button>`}
744
+ <button
745
+ className="btn ${editIndex !== null
746
+ ? "btn-warning"
747
+ : "btn-primary"} btn-sm"
748
+ onClick=${addOrUpdateField}
749
+ disabled=${!fname.trim()}
750
+ style=${{ padding: "5px 10px", whiteSpace: "nowrap" }}
751
+ >
752
+ ${editIndex !== null
753
+ ? "Update"
754
+ : html`<span className="sidebar-icon">${Icons.plus}</span>
755
+ Add`}
756
+ </button>
757
+ </div>
758
+ </div>
759
+
760
+ ${fields.length > 0 &&
761
+ html`<div
762
+ className="card mb-4"
763
+ style=${{ padding: 0, overflow: "hidden" }}
764
+ >
765
+ <div
766
+ style=${{
767
+ padding: "12px 16px",
768
+ borderBottom: "1px solid var(--border)",
769
+ display: "flex",
770
+ alignItems: "center",
771
+ justifyContent: "space-between",
772
+ }}
773
+ >
774
+ <span
775
+ style=${{
776
+ fontSize: "12px",
777
+ fontWeight: 600,
778
+ textTransform: "uppercase",
779
+ letterSpacing: "0.5px",
780
+ color: "var(--text-muted)",
781
+ }}
782
+ >Fields</span
783
+ >
784
+ <span className="field-count">${fields.length}</span>
785
+ </div>
786
+ <div style=${{ maxHeight: "340px", overflowY: "auto" }}>
787
+ ${fields.map(
788
+ (f, i) =>
789
+ html`<div
790
+ key=${i}
791
+ className="flex items-center gap-3"
792
+ style=${{
793
+ padding: "8px 16px",
794
+ borderBottom: "1px solid rgba(255,255,255,0.03)",
795
+ background:
796
+ editIndex === i ? "var(--warning-glow)" : "transparent",
797
+ transition: "background var(--transition)",
798
+ }}
799
+ onMouseEnter=${(e) => {
800
+ if (editIndex !== i)
801
+ e.currentTarget.style.background =
802
+ "rgba(255,255,255,0.02)";
803
+ }}
804
+ onMouseLeave=${(e) => {
805
+ if (editIndex !== i)
806
+ e.currentTarget.style.background = "transparent";
807
+ }}
808
+ >
809
+ <span
810
+ style=${{
811
+ fontSize: "11px",
812
+ color: "var(--text-muted)",
813
+ width: "20px",
814
+ fontVariantNumeric: "tabular-nums",
815
+ }}
816
+ >${i + 1}</span
817
+ >
818
+ <span
819
+ style=${{
820
+ fontWeight: 500,
821
+ fontSize: "13px",
822
+ flex: 1,
823
+ fontFamily: "'SF Mono', 'Fira Code', monospace",
824
+ }}
825
+ >${f.name}</span
826
+ >
827
+ <span
828
+ style=${{
829
+ fontSize: "11px",
830
+ padding: "2px 8px",
831
+ borderRadius: "4px",
832
+ background: "rgba(255,255,255,0.04)",
833
+ color: typeColors[f.type] || "var(--text-secondary)",
834
+ }}
835
+ >${f.type}${f.ref ? " \u2192 " + f.ref : ""}</span
836
+ >
837
+ ${f.required &&
838
+ html`<span
839
+ style=${{
840
+ fontSize: "9px",
841
+ padding: "2px 6px",
842
+ borderRadius: "4px",
843
+ background: "var(--success-glow)",
844
+ color: "var(--success)",
845
+ fontWeight: 600,
846
+ }}
847
+ >REQ</span
848
+ >`}
849
+ ${f.unique &&
850
+ html`<span
851
+ style=${{
852
+ fontSize: "9px",
853
+ padding: "2px 6px",
854
+ borderRadius: "4px",
855
+ background: "var(--warning-glow)",
856
+ color: "var(--warning)",
857
+ fontWeight: 600,
858
+ }}
859
+ >UNQ</span
860
+ >`}
861
+ <button
862
+ className="btn btn-ghost btn-sm btn-icon"
863
+ onClick=${() => startEdit(i)}
864
+ style=${{
865
+ color: "var(--accent-light)",
866
+ opacity: 0.6,
867
+ padding: "4px",
868
+ }}
869
+ >
870
+ <span className="sidebar-icon">${Icons.edit}</span>
871
+ </button>
872
+ <button
873
+ className="btn btn-ghost btn-sm btn-icon"
874
+ onClick=${() => removeField(i)}
875
+ style=${{
876
+ color: "var(--error)",
877
+ opacity: 0.5,
878
+ padding: "4px",
879
+ }}
880
+ >
881
+ <span className="sidebar-icon">${Icons.trash}</span>
882
+ </button>
883
+ </div>`,
884
+ )}
885
+ </div>
886
+ </div>`}
887
+ ${fields.length === 0 &&
888
+ html`<div
889
+ className="card mb-4"
890
+ style=${{
891
+ background: "var(--bg)",
892
+ padding: "30px",
893
+ textAlign: "center",
894
+ }}
895
+ >
896
+ <div style=${{ fontSize: "12px", color: "var(--text-muted)" }}>
897
+ Add fields above to define your schema
898
+ </div>
899
+ </div>`}
900
+ </div>`;
901
+ }