4bnode 4.0.9 → 4.1.1

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 (45) hide show
  1. package/README.md +25 -206
  2. package/add-crud.js +6 -106
  3. package/add-docs.js +91 -0
  4. package/add-email.js +63 -0
  5. package/add-security.js +143 -0
  6. package/index.js +76 -8
  7. package/lib/codegen.js +1223 -0
  8. package/lib/codegen.test.js +480 -0
  9. package/lib/indexFile.js +10 -12
  10. package/lib/names.js +4 -21
  11. package/lib/routeFile.js +9 -27
  12. package/lib/sync-skeleton.js +47 -0
  13. package/lib/ui.js +15 -2
  14. package/package.json +4 -2
  15. package/scripts/audit-dev-surface.mjs +153 -0
  16. package/scripts/reveal-passkey.mjs +35 -0
  17. package/skeleton/.4bnode/assets/js/App.js +31 -9
  18. package/skeleton/.4bnode/assets/js/Root.js +1 -5
  19. package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
  20. package/skeleton/.4bnode/assets/js/lib.js +18 -5
  21. package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  22. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
  23. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
  24. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
  25. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
  26. package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
  27. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
  28. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
  29. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  30. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
  31. package/skeleton/.4bnode/codegen.js +1223 -0
  32. package/skeleton/.4bnode/dev-api.js +634 -728
  33. package/skeleton/.4bnode/ui.html +62 -40
  34. package/skeleton/_gitignore +7 -0
  35. package/skeleton/index.js +41 -3
  36. package/skeleton/package.json +4 -1
  37. package/skeleton/src/middleware/apiKey.js +36 -0
  38. package/skeleton/src/middleware/errorHandler.js +15 -0
  39. package/skeleton/src/middleware/roles.js +14 -0
  40. package/skeleton/src/middleware/validate.js +20 -0
  41. package/skeleton/.4bnode/assets/images/mysql.png +0 -0
  42. package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
  43. package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
  44. package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
  45. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
@@ -1,6 +1,6 @@
1
1
  import React, { useState, useEffect, useRef } from "react";
2
- import { LuCheck, LuCopy, LuPlug, LuShare2 } from "react-icons/lu";
3
- import { html, api, Icons, copyToClipboard } from "../lib.js";
2
+ import { LuPlug } from "react-icons/lu";
3
+ import { html, api, Icons } from "../lib.js";
4
4
  import { useApp } from "../context.js";
5
5
 
6
6
  export function RoutesPage() {
@@ -9,13 +9,6 @@ export function RoutesPage() {
9
9
  const [selectedRoute, setSelectedRoute] = useState(null);
10
10
  const [routeContents, setRouteContents] = useState({});
11
11
  const [loadingRoute, setLoadingRoute] = useState(null);
12
- const [editingRoute, setEditingRoute] = useState(false);
13
- const [editCode, setEditCode] = useState("");
14
- const [savingRoute, setSavingRoute] = useState(false);
15
- const [showShare, setShowShare] = useState(false);
16
- const [shareData, setShareData] = useState(null);
17
- const [shareLoading, setShareLoading] = useState(false);
18
- const [shareCopied, setShareCopied] = useState(false);
19
12
  const [showAddMethod, setShowAddMethod] = useState(false);
20
13
  const [amRouteType, setAmRouteType] = useState(null);
21
14
  const [amMethods, setAmMethods] = useState([]);
@@ -38,9 +31,6 @@ export function RoutesPage() {
38
31
 
39
32
  async function selectRoute(filename) {
40
33
  setShowCreate(false);
41
- setEditingRoute(false);
42
- setShowShare(false);
43
- setShareData(null);
44
34
  setSelectedRoute(filename);
45
35
  if (!routeContents[filename]) {
46
36
  setLoadingRoute(filename);
@@ -66,108 +56,6 @@ export function RoutesPage() {
66
56
  return endpoints;
67
57
  }
68
58
 
69
- async function loadShareData(routeName) {
70
- setShareLoading(true);
71
- try {
72
- const res = await api('/routes/' + routeName + '/share');
73
- setShareData(res);
74
- } catch (err) { toast(err.message, 'error'); }
75
- setShareLoading(false);
76
- }
77
-
78
- function generateShareMarkdown() {
79
- if (!shareData) return '';
80
- const d = shareData;
81
- const capName = d.name.charAt(0).toUpperCase() + d.name.slice(1);
82
- const line = '\u2500'.repeat(35);
83
- const lines = [];
84
-
85
- d.endpoints.forEach((ep, idx) => {
86
- if (idx > 0) { lines.push(''); lines.push(''); }
87
- lines.push('\ud83d\udccc ' + capName + ' API');
88
- lines.push('');
89
- lines.push('Endpoint : ' + ep.method + ' ' + ep.fullPath);
90
- if (ep.bodyFields?.length > 0 || ['POST','PUT','PATCH'].includes(ep.method)) {
91
- lines.push('Content-Type : application/json');
92
- }
93
- if (ep.params?.length > 0) {
94
- lines.push('URL Params : ' + ep.params.map(p => ':' + p).join(', '));
95
- }
96
- if (ep.queryFields?.length > 0) {
97
- lines.push('Query Params : ' + ep.queryFields.map(f => f.name).join(', '));
98
- }
99
-
100
- if (ep.bodyFields?.length > 0) {
101
- lines.push('');
102
- lines.push(line);
103
- lines.push(' Request Body');
104
- lines.push(line);
105
-
106
- // Calculate column widths
107
- const fW = Math.max(5, ...ep.bodyFields.map(f => f.name.length)) + 2;
108
- const tW = Math.max(4, ...ep.bodyFields.map(f => (f.type || 'String').length)) + 2;
109
- const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
110
-
111
- lines.push(pad('Field', fW) + pad('Type', tW) + 'Required');
112
- lines.push(line);
113
- ep.bodyFields.forEach(f => {
114
- lines.push(pad(f.name, fW) + pad(f.type || 'String', tW) + (f.required ? '\u2705 Yes' : 'No'));
115
- });
116
-
117
- lines.push('');
118
- lines.push(line);
119
- lines.push(' Example');
120
- lines.push(line);
121
- const body = {};
122
- ep.bodyFields.forEach(f => {
123
- if (f.type === 'Number') body[f.name] = 0;
124
- else if (f.type === 'Boolean') body[f.name] = false;
125
- else if (f.type === 'Date') body[f.name] = '2024-01-01';
126
- else if (f.name.toLowerCase().includes('email')) body[f.name] = 'user@example.com';
127
- else if (f.name.toLowerCase().includes('password')) body[f.name] = 'your_password';
128
- else if (f.name.toLowerCase().includes('name')) body[f.name] = 'John';
129
- else if (f.name.toLowerCase().includes('phone')) body[f.name] = '+1234567890';
130
- else body[f.name] = '';
131
- });
132
- lines.push(JSON.stringify(body, null, 2));
133
- }
134
-
135
- // Response codes
136
- lines.push('');
137
- lines.push(line);
138
- lines.push(' Responses');
139
- lines.push(line);
140
- if (ep.method === 'GET') {
141
- lines.push('200 OK \u2192 Returns data');
142
- lines.push('404 Not Found \u2192 Resource not found');
143
- } else if (ep.method === 'POST') {
144
- lines.push('201 Created \u2192 Resource created successfully');
145
- lines.push('400 Bad Request \u2192 Missing or invalid fields');
146
- if (ep.bodyFields?.some(f => f.unique)) {
147
- lines.push('409 Conflict \u2192 Duplicate value for unique field');
148
- }
149
- } else if (ep.method === 'PUT' || ep.method === 'PATCH') {
150
- lines.push('200 OK \u2192 Resource updated successfully');
151
- lines.push('400 Bad Request \u2192 Missing or invalid fields');
152
- lines.push('404 Not Found \u2192 Resource not found');
153
- } else if (ep.method === 'DELETE') {
154
- lines.push('200 OK \u2192 Resource deleted successfully');
155
- lines.push('404 Not Found \u2192 Resource not found');
156
- }
157
- lines.push('401 Unauthorized \u2192 Authentication required');
158
- lines.push('500 Server Error \u2192 Internal server error');
159
- });
160
-
161
- return lines.join('\n');
162
- }
163
-
164
- function copyShareMarkdown() {
165
- copyToClipboard(generateShareMarkdown());
166
- setShareCopied(true);
167
- toast('Copied to clipboard', 'success');
168
- setTimeout(() => setShareCopied(false), 2000);
169
- }
170
-
171
59
  function getMethodsForFile(filename) {
172
60
  const content = routeContents[filename]?.content;
173
61
  if (!content) return [];
@@ -389,58 +277,6 @@ export function RoutesPage() {
389
277
  >
390
278
  ${showAddMethod ? "Cancel" : html`<span className="sidebar-icon">${Icons.plus}</span> Add`}
391
279
  </button>
392
- <button
393
- className="btn btn-sm btn-ghost"
394
- onClick=${() => {
395
- if (editingRoute) {
396
- setEditingRoute(false);
397
- } else {
398
- setEditCode(selContent || "");
399
- setEditingRoute(true);
400
- }
401
- }}
402
- >
403
- ${editingRoute ? "Cancel" : "Edit"}
404
- </button>
405
- ${editingRoute &&
406
- html`<button
407
- className="btn btn-sm btn-primary"
408
- disabled=${savingRoute}
409
- onClick=${async () => {
410
- setSavingRoute(true);
411
- try {
412
- await api("/routes/" + selectedRoute + "/content", {
413
- method: "PUT",
414
- body: { content: editCode },
415
- });
416
- toast("Route updated", "success");
417
- setRouteContents((prev) => ({
418
- ...prev,
419
- [selectedRoute]: { content: editCode },
420
- }));
421
- setEditingRoute(false);
422
- } catch (err) {
423
- toast(err.message, "error");
424
- }
425
- setSavingRoute(false);
426
- }}
427
- >
428
- ${savingRoute
429
- ? html`<span className="spinner" />`
430
- : "Save"}
431
- </button>`}
432
- <button
433
- className=${`btn btn-sm ${showShare ? 'btn-primary' : 'btn-ghost'}`}
434
- onClick=${() => {
435
- const next = !showShare;
436
- setShowShare(next);
437
- setShareCopied(false);
438
- if (next && !shareData) loadShareData(selectedRoute);
439
- }}
440
- title="Share endpoint details"
441
- >
442
- <${LuShare2} size=${14} />
443
- </button>
444
280
  <button
445
281
  className="btn btn-danger btn-sm btn-icon"
446
282
  onClick=${async () => {
@@ -452,7 +288,6 @@ export function RoutesPage() {
452
288
  });
453
289
  toast('Route "' + selName + '" deleted', "success");
454
290
  setSelectedRoute(null);
455
- setEditingRoute(false);
456
291
  setRouteContents((prev) => {
457
292
  const n = { ...prev };
458
293
  delete n[selectedRoute];
@@ -470,7 +305,6 @@ export function RoutesPage() {
470
305
  </div>
471
306
 
472
307
  ${selEndpoints.length > 0 &&
473
- !editingRoute &&
474
308
  html`<div>
475
309
  <div
476
310
  style=${{
@@ -510,7 +344,7 @@ export function RoutesPage() {
510
344
  const existingMethods = selEndpoints.map(ep => ep.method.toLowerCase());
511
345
  const amCanSubmit = amRouteType && (amRouteType !== "methods" || amMethods.length > 0) && (amRouteType !== "crud" || amMethods.length > 0) && (!amNeedsModel || amModelName) && (!amIsAuthType || amLoginField);
512
346
 
513
- return showAddMethod && !editingRoute && html`<div className="card mb-4">
347
+ return showAddMethod && html`<div className="card mb-4">
514
348
  <div style=${{ fontSize: "15px", fontWeight: 600, marginBottom: "14px" }}>Add to ${selName}</div>
515
349
 
516
350
  <div className="form-group">
@@ -726,61 +560,6 @@ export function RoutesPage() {
726
560
  </div>`;
727
561
  })()}
728
562
 
729
- ${showShare && !editingRoute && html`<div className="card mb-4" style=${{ overflow: 'hidden', padding: 0 }}>
730
- <div style=${{ padding: '14px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
731
- <div>
732
- <div style=${{ fontWeight: 600, fontSize: '14px' }}>Share with Frontend Team</div>
733
- <div className="text-sm text-muted">Copy endpoint details to share with your team</div>
734
- </div>
735
- <button className="btn btn-sm btn-primary" onClick=${copyShareMarkdown} disabled=${!shareData}>
736
- <${shareCopied ? LuCheck : LuCopy} size=${14} />
737
- ${' '}${shareCopied ? 'Copied!' : 'Copy Markdown'}
738
- </button>
739
- </div>
740
-
741
- ${shareLoading && html`<div style=${{ padding: '40px', textAlign: 'center' }}><span className="spinner" /></div>`}
742
-
743
- ${!shareLoading && shareData && html`<div style=${{ padding: '16px' }}>
744
- ${shareData.endpoints?.map((ep, idx) => html`<div key=${idx} style=${{ marginBottom: idx < shareData.endpoints.length - 1 ? '16px' : 0, border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
745
- <div style=${{ padding: '10px 14px', background: 'var(--bg-input)', display: 'flex', alignItems: 'center', gap: '10px' }}>
746
- <span className=${`route-method ${ep.method.toLowerCase()}`} style=${{ fontSize: '11px' }}>${ep.method}</span>
747
- <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '13px', fontWeight: 500, color: 'var(--text-primary)' }}>${ep.fullPath}</span>
748
- </div>
749
-
750
- ${(ep.params?.length > 0 || ep.queryFields?.length > 0 || ep.bodyFields?.length > 0) && html`<div style=${{ padding: '12px 14px' }}>
751
-
752
- ${ep.params?.length > 0 && html`<div style=${{ marginBottom: (ep.queryFields?.length > 0 || ep.bodyFields?.length > 0) ? '12px' : 0 }}>
753
- <div style=${{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.3px' }}>URL Params</div>
754
- ${ep.params.map(p => html`<div key=${p} style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', padding: '4px 10px', borderRadius: '6px', background: 'rgba(139,92,246,0.08)', marginRight: '6px', fontSize: '12px' }}>
755
- <span style=${{ fontFamily: "'SF Mono', monospace", color: 'var(--accent-light)', fontWeight: 500 }}>:${p}</span>
756
- </div>`)}
757
- </div>`}
758
-
759
- ${ep.queryFields?.length > 0 && html`<div style=${{ marginBottom: ep.bodyFields?.length > 0 ? '12px' : 0 }}>
760
- <div style=${{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.3px' }}>Query Params</div>
761
- ${ep.queryFields.map(f => html`<span key=${f.name} style=${{ display: 'inline-block', padding: '4px 10px', borderRadius: '6px', background: 'var(--bg-input)', fontFamily: "'SF Mono', monospace", fontSize: '12px', marginRight: '6px' }}>${f.name}</span>`)}
762
- </div>`}
763
-
764
- ${ep.bodyFields?.length > 0 && html`<div>
765
- <div style=${{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.3px' }}>Body Fields <span style=${{ fontWeight: 400, textTransform: 'none', letterSpacing: 0, fontStyle: 'italic' }}>application/json</span></div>
766
- <div style=${{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' }}>
767
- ${ep.bodyFields.map(f => html`<div key=${f.name} style=${{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 10px', borderRadius: '6px', background: 'var(--bg-input)' }}>
768
- <span style=${{ fontFamily: "'SF Mono', monospace", fontSize: '12px', fontWeight: 500, color: 'var(--text-primary)', minWidth: '100px' }}>${f.name}</span>
769
- <span style=${{ padding: '1px 7px', borderRadius: '4px', background: 'var(--accent-glow)', color: 'var(--accent-light)', fontSize: '10px', fontWeight: 500 }}>${f.type}</span>
770
- ${f.required && html`<span style=${{ padding: '1px 7px', borderRadius: '4px', background: 'rgba(52,211,153,0.12)', color: 'var(--success)', fontSize: '10px', fontWeight: 600 }}>required</span>`}
771
- ${f.unique && html`<span style=${{ padding: '1px 7px', borderRadius: '4px', background: 'rgba(251,191,36,0.12)', color: 'var(--warning)', fontSize: '10px', fontWeight: 600 }}>unique</span>`}
772
- </div>`)}
773
- </div>
774
- <div style=${{ padding: '10px 12px', background: 'var(--bg-input)', borderRadius: '6px' }}>
775
- <div style=${{ fontSize: '10px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.3px', color: 'var(--text-muted)', marginBottom: '6px' }}>Example</div>
776
- <pre style=${{ margin: 0, fontFamily: "'SF Mono', monospace", fontSize: '12px', lineHeight: '1.5', color: 'var(--text-secondary)' }}>${JSON.stringify(Object.fromEntries(ep.bodyFields.map(f => [f.name, f.type === 'Number' ? 0 : f.type === 'Boolean' ? false : f.type === 'Date' ? '2024-01-01' : ''])), null, 2)}</pre>
777
- </div>
778
- </div>`}
779
- </div>`}
780
- </div>`)}
781
- </div>`}
782
- </div>`}
783
-
784
563
  <div
785
564
  className="card"
786
565
  style=${{ padding: 0, overflow: "hidden" }}
@@ -800,9 +579,7 @@ export function RoutesPage() {
800
579
  fontWeight: 600,
801
580
  color: "var(--text-secondary)",
802
581
  }}
803
- >${editingRoute
804
- ? "Edit Source Code"
805
- : "Source Code"}</span
582
+ >Source Code</span
806
583
  >
807
584
  <span className="field-count"
808
585
  >${selContent
@@ -814,45 +591,7 @@ export function RoutesPage() {
814
591
  html`<div style=${{ padding: "30px", textAlign: "center" }}>
815
592
  <span className="spinner" />
816
593
  </div>`}
817
- ${editingRoute &&
818
- html`<textarea
819
- value=${editCode}
820
- onInput=${(e) => setEditCode(e.target.value)}
821
- style=${{
822
- width: "100%",
823
- minHeight: "400px",
824
- padding: "16px",
825
- border: "none",
826
- outline: "none",
827
- fontFamily:
828
- "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
829
- fontSize: "13px",
830
- lineHeight: "1.6",
831
- background: "var(--bg-card)",
832
- color: "var(--text-primary)",
833
- resize: "vertical",
834
- tabSize: 2,
835
- }}
836
- spellcheck=${false}
837
- onKeyDown=${(e) => {
838
- if (e.key === "Tab") {
839
- e.preventDefault();
840
- const start = e.target.selectionStart;
841
- const end = e.target.selectionEnd;
842
- const newVal =
843
- editCode.substring(0, start) +
844
- " " +
845
- editCode.substring(end);
846
- setEditCode(newVal);
847
- setTimeout(() => {
848
- e.target.selectionStart = e.target.selectionEnd =
849
- start + 2;
850
- }, 0);
851
- }
852
- }}
853
- />`}
854
- ${!editingRoute &&
855
- selContent &&
594
+ ${selContent &&
856
595
  html`<div className="route-code-wrap">
857
596
  <div className="code-block">${selContent}</div>
858
597
  </div>`}
@@ -1,6 +1,5 @@
1
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";
2
+ import { LuFileCode, LuLock } from "react-icons/lu";
4
3
  import { html, api, Icons } from "../lib.js";
5
4
  import { useApp } from "../context.js";
6
5
 
@@ -225,105 +224,9 @@ export function SchemasPage() {
225
224
  }
226
225
 
227
226
  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
227
  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
228
  const tdStyle = { padding: '8px 16px', fontSize: '12px', borderBottom: '1px solid var(--border)' };
321
229
 
322
- const tabs = [
323
- { id: 'fields', label: 'Fields', icon: LuFileText },
324
- { id: 'data', label: 'Data', icon: LuTable },
325
- ];
326
-
327
230
  return html`<div>
328
231
  <div className="card mb-4">
329
232
  <div className="flex items-center justify-between mb-3">
@@ -350,122 +253,29 @@ function SchemaDetailPanel({ model, reqCount, uniqCount, onEdit, onDelete }) {
350
253
  </div>
351
254
  </div>
352
255
 
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>`)}
256
+ <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
257
+ <div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
258
+ <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Schema Fields</span>
259
+ </div>
260
+ <table style=${{ width: '100%' }}>
261
+ <thead><tr>
262
+ <th style=${thStyle}>#</th><th style=${thStyle}>Name</th><th style=${thStyle}>Type</th><th style=${thStyle}>Properties</th>
263
+ </tr></thead>
264
+ <tbody>
265
+ ${model.fields?.map((f, i) => html`<tr key=${f.name} style=${{ borderBottom: '1px solid var(--border)' }}>
266
+ <td style=${{ ...tdStyle, color: 'var(--text-muted)' }}>${i + 1}</td>
267
+ <td style=${{ ...tdStyle, fontWeight: 500, fontSize: '13px', fontFamily: "'SF Mono', monospace" }}>${f.name}</td>
268
+ <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>
269
+ <td style=${tdStyle}><div className="flex gap-1">
270
+ ${f.required && html`<span className="badge badge-success" style=${{ fontSize: '10px' }}>required</span>`}
271
+ ${f.unique && html`<span className="badge badge-warning" style=${{ fontSize: '10px' }}>unique</span>`}
272
+ ${f.index && html`<span className="badge badge-info" style=${{ fontSize: '10px' }}>indexed</span>`}
273
+ ${!f.required && !f.unique && !f.index && html`<span style=${{ fontSize: '12px', color: 'var(--text-muted)' }}>—</span>`}
274
+ </div></td>
275
+ </tr>`)}
276
+ </tbody>
277
+ </table>
361
278
  </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.index && html`<span className="badge badge-info" style=${{ fontSize: '10px' }}>indexed</span>`}
383
- ${!f.required && !f.unique && !f.index && html`<span style=${{ fontSize: '12px', color: 'var(--text-muted)' }}>—</span>`}
384
- </div></td>
385
- </tr>`)}
386
- </tbody>
387
- </table>
388
- </div>`}
389
-
390
- ${tab === 'data' && html`<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
391
- <div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
392
- <span style=${{ fontSize: '12px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', flex: 1 }}>
393
- Collection Data${dataTotal > 0 ? ' (' + dataTotal + ' documents)' : ''}
394
- </span>
395
- <button className="btn btn-sm btn-ghost" onClick=${() => setShowColPicker(!showColPicker)} title="Select columns">
396
- <${showColPicker ? LuEyeOff : LuEye} size=${14} />
397
- </button>
398
- <div style=${{ position: 'relative' }}>
399
- <button className="btn btn-sm btn-ghost" onClick=${() => setShowDownloadMenu(!showDownloadMenu)} disabled=${!docs.length && !dataTotal} title="Download CSV">
400
- ${downloadingAll ? html`<span className="spinner" style=${{ width: '14px', height: '14px' }} />` : html`<${LuDownload} size=${14} />`}
401
- </button>
402
- ${showDownloadMenu && html`<div style=${{
403
- position: 'absolute', top: '100%', right: 0, marginTop: '4px', zIndex: 10,
404
- background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: '8px',
405
- boxShadow: '0 8px 24px rgba(0,0,0,0.3)', minWidth: '200px', overflow: 'hidden'
406
- }}>
407
- <button onClick=${downloadCurrentPage} disabled=${!docs.length}
408
- style=${{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%', padding: '10px 14px',
409
- background: 'transparent', border: 'none', color: !docs.length ? 'var(--text-muted)' : 'var(--text-secondary)',
410
- fontSize: '13px', cursor: !docs.length ? 'not-allowed' : 'pointer', fontFamily: 'inherit', textAlign: 'left' }}
411
- onMouseEnter=${e => { if (docs.length) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
412
- onMouseLeave=${e => { e.currentTarget.style.background = 'transparent'; }}>
413
- <${LuDownload} size=${13} /> Current page (${docs.length} rows)
414
- </button>
415
- <button onClick=${downloadAllData}
416
- style=${{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%', padding: '10px 14px',
417
- background: 'transparent', border: 'none', color: 'var(--text-secondary)',
418
- fontSize: '13px', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
419
- borderTop: '1px solid var(--border)' }}
420
- onMouseEnter=${e => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
421
- onMouseLeave=${e => { e.currentTarget.style.background = 'transparent'; }}>
422
- <${LuDownload} size=${13} /> All data (${dataTotal} rows)
423
- </button>
424
- </div>`}
425
- </div>
426
- <button className="btn btn-sm btn-ghost" onClick=${() => loadData(dataPage)} title="Refresh">
427
- <${LuTable} size=${14} />
428
- </button>
429
- </div>
430
- ${showColPicker && html`<div style=${{ padding: '10px 16px', borderBottom: '1px solid var(--border)', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
431
- ${allFields.map(f => html`<button key=${f}
432
- onClick=${() => {
433
- const next = cols.includes(f) ? cols.filter(c => c !== f) : [...cols, f];
434
- if (next.length > 0) setVisibleCols(next);
435
- }}
436
- style=${{ padding: '3px 10px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--border)', cursor: 'pointer', fontFamily: 'monospace',
437
- background: cols.includes(f) ? 'var(--accent-glow)' : 'transparent', color: cols.includes(f) ? 'var(--accent-light)' : 'var(--text-muted)' }}>
438
- ${f}
439
- </button>`)}
440
- </div>`}
441
- ${dataLoading && html`<div style=${{ padding: '40px', textAlign: 'center' }}><span className="spinner" /></div>`}
442
- ${!dataLoading && docs.length === 0 && html`<div style=${{ padding: '40px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '13px' }}>No documents found in this collection</div>`}
443
- ${!dataLoading && docs.length > 0 && html`<div style=${{ overflowX: 'auto' }}>
444
- <table style=${{ width: '100%', minWidth: cols.length * 140 + 'px' }}>
445
- <thead><tr>${cols.map(c => html`<th key=${c} style=${thStyle}>${c}</th>`)}</tr></thead>
446
- <tbody>
447
- ${docs.map((doc, i) => html`<tr key=${i} style=${{ borderBottom: '1px solid var(--border)' }}>
448
- ${cols.map(c => {
449
- const val = doc[c];
450
- let display = val === null || val === undefined ? '—' : typeof val === 'object' ? JSON.stringify(val) : String(val);
451
- if (display.length > 60) display = display.slice(0, 57) + '...';
452
- return html`<td key=${c} style=${{ ...tdStyle, fontFamily: c === '_id' ? 'monospace' : 'inherit', fontSize: '12px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${display}</td>`;
453
- })}
454
- </tr>`)}
455
- </tbody>
456
- </table>
457
- </div>`}
458
- ${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)' }}>
459
- <span>Page ${dataPage + 1} of ${Math.ceil(dataTotal / pageSize)}</span>
460
- <div className="flex gap-2">
461
- <button className="btn btn-sm btn-ghost" disabled=${dataPage === 0} onClick=${() => loadData(dataPage - 1)}><${LuChevronLeft} size=${14} /></button>
462
- <button className="btn btn-sm btn-ghost" disabled=${(dataPage + 1) * pageSize >= dataTotal} onClick=${() => loadData(dataPage + 1)}><${LuChevronRight} size=${14} /></button>
463
- </div>
464
- </div>`}
465
- </div>`}
466
-
467
- <//>
468
- <//>
469
279
  </div>`;
470
280
  }
471
281