4bnode 4.1.0 → 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.
- package/README.md +25 -206
- package/add-crud.js +6 -106
- package/add-docs.js +91 -0
- package/add-email.js +63 -0
- package/add-security.js +143 -0
- package/index.js +76 -8
- package/lib/codegen.js +1223 -0
- package/lib/codegen.test.js +480 -0
- package/lib/indexFile.js +10 -12
- package/lib/names.js +4 -21
- package/lib/routeFile.js +9 -27
- package/lib/sync-skeleton.js +47 -0
- package/lib/ui.js +15 -2
- package/package.json +4 -2
- package/scripts/audit-dev-surface.mjs +153 -0
- package/scripts/reveal-passkey.mjs +35 -0
- package/skeleton/.4bnode/assets/js/App.js +31 -9
- package/skeleton/.4bnode/assets/js/Root.js +1 -5
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
- package/skeleton/.4bnode/assets/js/lib.js +18 -5
- package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
- package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
- package/skeleton/.4bnode/codegen.js +1223 -0
- package/skeleton/.4bnode/dev-api.js +634 -728
- package/skeleton/.4bnode/ui.html +62 -40
- package/skeleton/_gitignore +7 -0
- package/skeleton/index.js +46 -5
- package/skeleton/package.json +4 -1
- package/skeleton/src/middleware/apiKey.js +36 -0
- package/skeleton/src/middleware/errorHandler.js +15 -0
- package/skeleton/src/middleware/roles.js +14 -0
- package/skeleton/src/middleware/validate.js +20 -0
- package/skeleton/.4bnode/assets/images/mysql.png +0 -0
- package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
- package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
- package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
- 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 {
|
|
3
|
-
import { html, api, Icons
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
${
|
|
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 {
|
|
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=${{
|
|
354
|
-
|
|
355
|
-
style=${{
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|