4bnode 4.0.0 → 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.
- package/README.md +10 -0
- package/add-login.js +2 -2
- package/index.js +57 -5
- package/lib/indexFile.js +60 -13
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +115 -0
- package/skeleton/.4bnode/assets/js/Root.js +44 -0
- package/skeleton/.4bnode/assets/js/components/Modal.js +52 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +68 -0
- package/skeleton/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/skeleton/.4bnode/assets/js/context.js +7 -0
- package/skeleton/.4bnode/assets/js/lib.js +83 -0
- package/skeleton/.4bnode/assets/js/main.js +5 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +1594 -0
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +461 -0
- package/skeleton/.4bnode/assets/js/pages/EnvironmentPage.js +141 -0
- package/skeleton/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1656 -0
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +901 -0
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +66 -0
- package/skeleton/{dev-api.js → .4bnode/dev-api.js} +259 -114
- package/skeleton/.4bnode/ui.html +1556 -0
- package/skeleton/index.js +62 -16
- package/skeleton/package.json +1 -1
- package/skeleton/public/index.html +290 -7586
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Bold.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Light.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Medium.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Regular.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-SemiBold.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/images/mongo.png +0 -0
- /package/skeleton/{public → .4bnode/assets}/images/mysql.png +0 -0
- /package/skeleton/{public → .4bnode/assets}/images/postgresql.png +0 -0
- /package/skeleton/{public → .4bnode/assets}/images/sqlite.png +0 -0
|
@@ -0,0 +1,1656 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { LuCheck, LuCopy, LuPlug, LuShare2 } from "react-icons/lu";
|
|
3
|
+
import { html, api, Icons } from "../lib.js";
|
|
4
|
+
import { useApp } from "../context.js";
|
|
5
|
+
|
|
6
|
+
export function RoutesPage() {
|
|
7
|
+
const { data, toast, refresh, showConfirm } = useApp();
|
|
8
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
9
|
+
const [selectedRoute, setSelectedRoute] = useState(null);
|
|
10
|
+
const [routeContents, setRouteContents] = useState({});
|
|
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
|
+
const [showAddMethod, setShowAddMethod] = useState(false);
|
|
20
|
+
const [amRouteType, setAmRouteType] = useState(null);
|
|
21
|
+
const [amMethods, setAmMethods] = useState([]);
|
|
22
|
+
const [amModelName, setAmModelName] = useState("");
|
|
23
|
+
const [amLoginField, setAmLoginField] = useState("");
|
|
24
|
+
const [amPasswordField, setAmPasswordField] = useState("");
|
|
25
|
+
const [amRegisterFields, setAmRegisterFields] = useState([]);
|
|
26
|
+
const [amSelectedFields, setAmSelectedFields] = useState([]);
|
|
27
|
+
const [amCustomFields, setAmCustomFields] = useState([]);
|
|
28
|
+
const [amNewFieldName, setAmNewFieldName] = useState("");
|
|
29
|
+
const [amNewFieldType, setAmNewFieldType] = useState("String");
|
|
30
|
+
const [amLoading, setAmLoading] = useState(false);
|
|
31
|
+
|
|
32
|
+
// Auto-select first route
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (data.routes?.length > 0 && !selectedRoute && !showCreate) {
|
|
35
|
+
selectRoute(data.routes[0]);
|
|
36
|
+
}
|
|
37
|
+
}, [data.routes]);
|
|
38
|
+
|
|
39
|
+
async function selectRoute(filename) {
|
|
40
|
+
setShowCreate(false);
|
|
41
|
+
setEditingRoute(false);
|
|
42
|
+
setShowShare(false);
|
|
43
|
+
setShareData(null);
|
|
44
|
+
setSelectedRoute(filename);
|
|
45
|
+
if (!routeContents[filename]) {
|
|
46
|
+
setLoadingRoute(filename);
|
|
47
|
+
try {
|
|
48
|
+
const result = await api("/routes/" + filename + "/content");
|
|
49
|
+
setRouteContents((prev) => ({ ...prev, [filename]: result }));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
toast(err.message, "error");
|
|
52
|
+
}
|
|
53
|
+
setLoadingRoute(null);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseEndpoints(content) {
|
|
58
|
+
if (!content) return [];
|
|
59
|
+
const endpoints = [];
|
|
60
|
+
const regex =
|
|
61
|
+
/router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]/gi;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = regex.exec(content)) !== null) {
|
|
64
|
+
endpoints.push({ method: m[1].toUpperCase(), path: m[2] });
|
|
65
|
+
}
|
|
66
|
+
return endpoints;
|
|
67
|
+
}
|
|
68
|
+
|
|
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
|
+
navigator.clipboard.writeText(generateShareMarkdown());
|
|
166
|
+
setShareCopied(true);
|
|
167
|
+
toast('Copied to clipboard', 'success');
|
|
168
|
+
setTimeout(() => setShareCopied(false), 2000);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getMethodsForFile(filename) {
|
|
172
|
+
const content = routeContents[filename]?.content;
|
|
173
|
+
if (!content) return [];
|
|
174
|
+
const methods = [];
|
|
175
|
+
const regex = /router\.(get|post|put|patch|delete)\(/gi;
|
|
176
|
+
let m;
|
|
177
|
+
while ((m = regex.exec(content)) !== null) {
|
|
178
|
+
const method = m[1].toUpperCase();
|
|
179
|
+
if (!methods.includes(method)) methods.push(method);
|
|
180
|
+
}
|
|
181
|
+
return methods;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const selName = selectedRoute?.replace(".js", "") || "";
|
|
185
|
+
const selContent = routeContents[selectedRoute]?.content;
|
|
186
|
+
const selEndpoints = parseEndpoints(selContent);
|
|
187
|
+
const hasAuth = selContent?.includes("import auth") || false;
|
|
188
|
+
const hasModel = selContent?.match(
|
|
189
|
+
/import\s+(\w+)\s+from\s+'\.\.\/models/,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// When no routes yet, show empty + create in a simpler layout
|
|
193
|
+
if (data.routes?.length === 0 && !showCreate) {
|
|
194
|
+
return html`<div>
|
|
195
|
+
<div className="flex items-center justify-between mb-4">
|
|
196
|
+
<div>
|
|
197
|
+
<div className="card-title">API Routes</div>
|
|
198
|
+
<div className="card-subtitle">0 route files</div>
|
|
199
|
+
</div>
|
|
200
|
+
<button
|
|
201
|
+
className="btn btn-primary"
|
|
202
|
+
onClick=${() => setShowCreate(true)}
|
|
203
|
+
>
|
|
204
|
+
<span className="sidebar-icon">${Icons.plus}</span> New Route
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
<div className="empty-state">
|
|
208
|
+
<div className="empty-icon"><${LuPlug} size=${36} /></div>
|
|
209
|
+
<h3>No routes yet</h3>
|
|
210
|
+
<p>Create your first API endpoint to get started</p>
|
|
211
|
+
<button
|
|
212
|
+
className="btn btn-primary"
|
|
213
|
+
onClick=${() => setShowCreate(true)}
|
|
214
|
+
>
|
|
215
|
+
Create Route
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return html`<div>
|
|
222
|
+
<div className="flex items-center justify-between mb-4">
|
|
223
|
+
<div>
|
|
224
|
+
<div className="card-title">API Routes</div>
|
|
225
|
+
<div className="card-subtitle">
|
|
226
|
+
${data.routes?.length || 0} route
|
|
227
|
+
file${data.routes?.length !== 1 ? "s" : ""}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<button
|
|
231
|
+
className=${`btn ${showCreate ? "btn-ghost" : "btn-primary"}`}
|
|
232
|
+
onClick=${() => {
|
|
233
|
+
if (showCreate) {
|
|
234
|
+
setShowCreate(false);
|
|
235
|
+
if (data.routes?.length) selectRoute(data.routes[0]);
|
|
236
|
+
} else {
|
|
237
|
+
setShowCreate(true);
|
|
238
|
+
setSelectedRoute(null);
|
|
239
|
+
}
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
${showCreate
|
|
243
|
+
? "Cancel"
|
|
244
|
+
: html`<span className="sidebar-icon">${Icons.plus}</span> New
|
|
245
|
+
Route`}
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="routes-layout">
|
|
250
|
+
<div className="card" style=${{ padding: 0, overflow: "hidden" }}>
|
|
251
|
+
<div
|
|
252
|
+
style=${{
|
|
253
|
+
padding: "14px 16px 10px",
|
|
254
|
+
borderBottom: "1px solid var(--border)",
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
<span
|
|
258
|
+
style=${{
|
|
259
|
+
fontWeight: 600,
|
|
260
|
+
fontSize: "12px",
|
|
261
|
+
textTransform: "uppercase",
|
|
262
|
+
letterSpacing: "0.5px",
|
|
263
|
+
color: "var(--text-muted)",
|
|
264
|
+
}}
|
|
265
|
+
>Files</span
|
|
266
|
+
>
|
|
267
|
+
</div>
|
|
268
|
+
<div className="routes-list">
|
|
269
|
+
${(data.routes || []).map((r) => {
|
|
270
|
+
const rName = r.replace(".js", "");
|
|
271
|
+
const methods = getMethodsForFile(r);
|
|
272
|
+
const isActive = selectedRoute === r && !showCreate;
|
|
273
|
+
return html`<div
|
|
274
|
+
key=${r}
|
|
275
|
+
className=${`route-item ${isActive ? "active" : ""}`}
|
|
276
|
+
onClick=${() => selectRoute(r)}
|
|
277
|
+
>
|
|
278
|
+
<div
|
|
279
|
+
style=${{
|
|
280
|
+
display: "flex",
|
|
281
|
+
flexDirection: "column",
|
|
282
|
+
gap: "4px",
|
|
283
|
+
flex: 1,
|
|
284
|
+
minWidth: 0,
|
|
285
|
+
}}
|
|
286
|
+
>
|
|
287
|
+
<span className="ri-name">${rName}</span>
|
|
288
|
+
<span className="ri-file">src/routes/${r}</span>
|
|
289
|
+
</div>
|
|
290
|
+
<div className="flex gap-1" style=${{ flexWrap: "wrap" }}>
|
|
291
|
+
${methods.length > 0
|
|
292
|
+
? methods.map(
|
|
293
|
+
(m) =>
|
|
294
|
+
html`<span
|
|
295
|
+
key=${m}
|
|
296
|
+
className=${`route-method ${m.toLowerCase()}`}
|
|
297
|
+
>${m}</span
|
|
298
|
+
>`,
|
|
299
|
+
)
|
|
300
|
+
: html`<span className="route-method multi">...</span>`}
|
|
301
|
+
</div>
|
|
302
|
+
</div>`;
|
|
303
|
+
})}
|
|
304
|
+
<div
|
|
305
|
+
className=${`route-item ${showCreate ? "active" : ""}`}
|
|
306
|
+
onClick=${() => {
|
|
307
|
+
setShowCreate(true);
|
|
308
|
+
setSelectedRoute(null);
|
|
309
|
+
}}
|
|
310
|
+
style=${{
|
|
311
|
+
borderTop: data.routes?.length
|
|
312
|
+
? "1px solid var(--border)"
|
|
313
|
+
: "none",
|
|
314
|
+
}}
|
|
315
|
+
>
|
|
316
|
+
<span
|
|
317
|
+
className="sidebar-icon"
|
|
318
|
+
style=${{ color: "var(--accent-light)" }}
|
|
319
|
+
>${Icons.plus}</span
|
|
320
|
+
>
|
|
321
|
+
<span
|
|
322
|
+
className="ri-name"
|
|
323
|
+
style=${{ color: "var(--accent-light)" }}
|
|
324
|
+
>New Route</span
|
|
325
|
+
>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<div className="routes-detail">
|
|
331
|
+
${showCreate &&
|
|
332
|
+
html`<${CreateRoutePanel}
|
|
333
|
+
onCreated=${() => {
|
|
334
|
+
setShowCreate(false);
|
|
335
|
+
refresh();
|
|
336
|
+
}}
|
|
337
|
+
/>`}
|
|
338
|
+
${!showCreate &&
|
|
339
|
+
!selectedRoute &&
|
|
340
|
+
html`<div className="card">
|
|
341
|
+
<div className="empty-state" style=${{ padding: "40px" }}>
|
|
342
|
+
<p>Select a route to view details</p>
|
|
343
|
+
</div>
|
|
344
|
+
</div>`}
|
|
345
|
+
${!showCreate &&
|
|
346
|
+
selectedRoute &&
|
|
347
|
+
html`<div>
|
|
348
|
+
<div className="card mb-4">
|
|
349
|
+
<div className="flex items-center justify-between mb-3">
|
|
350
|
+
<div>
|
|
351
|
+
<div
|
|
352
|
+
style=${{
|
|
353
|
+
fontWeight: 600,
|
|
354
|
+
fontSize: "16px",
|
|
355
|
+
marginBottom: "2px",
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
${selName}
|
|
359
|
+
</div>
|
|
360
|
+
<div
|
|
361
|
+
className="text-sm text-muted"
|
|
362
|
+
style=${{
|
|
363
|
+
fontFamily: "'SF Mono', 'Fira Code', monospace",
|
|
364
|
+
}}
|
|
365
|
+
>
|
|
366
|
+
src/routes/${selectedRoute}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
<div className="flex gap-2 items-center">
|
|
370
|
+
${hasAuth &&
|
|
371
|
+
html`<span className="badge badge-warning">Auth</span>`}
|
|
372
|
+
${hasModel &&
|
|
373
|
+
html`<span className="badge badge-info"
|
|
374
|
+
>Model: ${hasModel[1]}</span
|
|
375
|
+
>`}
|
|
376
|
+
<button
|
|
377
|
+
className=${`btn btn-sm ${showAddMethod ? 'btn-primary' : 'btn-ghost'}`}
|
|
378
|
+
onClick=${() => {
|
|
379
|
+
setShowAddMethod(!showAddMethod);
|
|
380
|
+
setAmRouteType(null);
|
|
381
|
+
setAmMethods([]);
|
|
382
|
+
setAmModelName("");
|
|
383
|
+
setAmLoginField("");
|
|
384
|
+
setAmPasswordField("");
|
|
385
|
+
setAmRegisterFields([]);
|
|
386
|
+
setAmSelectedFields([]);
|
|
387
|
+
setAmCustomFields([]);
|
|
388
|
+
}}
|
|
389
|
+
>
|
|
390
|
+
${showAddMethod ? "Cancel" : html`<span className="sidebar-icon">${Icons.plus}</span> Add`}
|
|
391
|
+
</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
|
+
<button
|
|
445
|
+
className="btn btn-danger btn-sm btn-icon"
|
|
446
|
+
onClick=${async () => {
|
|
447
|
+
const ok = await showConfirm('Delete route "' + selName + '"? This will remove the route file and its registration from index.js.', { danger: true, confirmLabel: 'Delete Route' });
|
|
448
|
+
if (!ok) return;
|
|
449
|
+
try {
|
|
450
|
+
await api("/routes/" + selectedRoute, {
|
|
451
|
+
method: "DELETE",
|
|
452
|
+
});
|
|
453
|
+
toast('Route "' + selName + '" deleted', "success");
|
|
454
|
+
setSelectedRoute(null);
|
|
455
|
+
setEditingRoute(false);
|
|
456
|
+
setRouteContents((prev) => {
|
|
457
|
+
const n = { ...prev };
|
|
458
|
+
delete n[selectedRoute];
|
|
459
|
+
return n;
|
|
460
|
+
});
|
|
461
|
+
refresh();
|
|
462
|
+
} catch (err) {
|
|
463
|
+
toast(err.message, "error");
|
|
464
|
+
}
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
<span className="sidebar-icon">${Icons.trash}</span>
|
|
468
|
+
</button>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
${selEndpoints.length > 0 &&
|
|
473
|
+
!editingRoute &&
|
|
474
|
+
html`<div>
|
|
475
|
+
<div
|
|
476
|
+
style=${{
|
|
477
|
+
fontSize: "11px",
|
|
478
|
+
fontWeight: 600,
|
|
479
|
+
textTransform: "uppercase",
|
|
480
|
+
letterSpacing: "0.5px",
|
|
481
|
+
color: "var(--text-muted)",
|
|
482
|
+
marginBottom: "8px",
|
|
483
|
+
}}
|
|
484
|
+
>
|
|
485
|
+
Endpoints
|
|
486
|
+
</div>
|
|
487
|
+
${selEndpoints.map(
|
|
488
|
+
(ep, i) =>
|
|
489
|
+
html`<div className="route-endpoint-row" key=${i}>
|
|
490
|
+
<span
|
|
491
|
+
className=${`route-method ${ep.method.toLowerCase()}`}
|
|
492
|
+
>${ep.method}</span
|
|
493
|
+
>
|
|
494
|
+
<span className="route-endpoint-path"
|
|
495
|
+
>/api/${selName}${ep.path === "/"
|
|
496
|
+
? ""
|
|
497
|
+
: ep.path}</span
|
|
498
|
+
>
|
|
499
|
+
</div>`,
|
|
500
|
+
)}
|
|
501
|
+
</div>`}
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
${(() => {
|
|
505
|
+
const amNeedsModel = amRouteType && amRouteType !== "methods" && amRouteType !== "plain";
|
|
506
|
+
const amIsAuthType = amRouteType === "login" || amRouteType === "register";
|
|
507
|
+
const amModelObj = data.models?.find(m => m.name === amModelName);
|
|
508
|
+
const amModelFields = amModelObj?.fields || [];
|
|
509
|
+
const amMethodColors = { get: "#22c55e", post: "#3b82f6", put: "#f59e0b", patch: "#a855f7", delete: "#ef4444" };
|
|
510
|
+
const existingMethods = selEndpoints.map(ep => ep.method.toLowerCase());
|
|
511
|
+
const amCanSubmit = amRouteType && (amRouteType !== "methods" || amMethods.length > 0) && (amRouteType !== "crud" || amMethods.length > 0) && (!amNeedsModel || amModelName) && (!amIsAuthType || amLoginField);
|
|
512
|
+
|
|
513
|
+
return showAddMethod && !editingRoute && html`<div className="card mb-4">
|
|
514
|
+
<div style=${{ fontSize: "15px", fontWeight: 600, marginBottom: "14px" }}>Add to ${selName}</div>
|
|
515
|
+
|
|
516
|
+
<div className="form-group">
|
|
517
|
+
<label className="form-label">Type</label>
|
|
518
|
+
<div style=${{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: "6px" }}>
|
|
519
|
+
${[
|
|
520
|
+
{ id: "methods", label: "Methods", desc: "GET, POST, PUT...", color: "var(--accent-light)" },
|
|
521
|
+
{ id: "crud", label: "CRUD", desc: "Model CRUD ops", color: "#3b82f6" },
|
|
522
|
+
{ id: "login", label: "Login", desc: "JWT login", color: "#22d3ee" },
|
|
523
|
+
{ id: "register", label: "Register", desc: "Sign up", color: "#34d399" },
|
|
524
|
+
].map(t => {
|
|
525
|
+
const active = amRouteType === t.id;
|
|
526
|
+
return html`<button key=${t.id} style=${{
|
|
527
|
+
padding: "8px 4px", borderRadius: "var(--radius)", cursor: "pointer", textAlign: "center",
|
|
528
|
+
border: active ? "1.5px solid " + t.color : "1.5px solid var(--border)",
|
|
529
|
+
background: active ? "rgba(255,255,255,0.04)" : "var(--bg-input)",
|
|
530
|
+
opacity: active ? 1 : 0.5, transition: "all 0.15s",
|
|
531
|
+
}} onClick=${() => { setAmRouteType(prev => prev === t.id ? null : t.id); if (t.id !== "methods" && t.id !== "crud") setAmMethods([]); }}>
|
|
532
|
+
<div style=${{ fontSize: "11px", fontWeight: 700, color: active ? t.color : "var(--text-secondary)" }}>${t.label}</div>
|
|
533
|
+
<div style=${{ fontSize: "9px", color: "var(--text-muted)", marginTop: "1px" }}>${t.desc}</div>
|
|
534
|
+
</button>`;
|
|
535
|
+
})}
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
${amRouteType === "methods" && html`<div className="form-group">
|
|
540
|
+
<label className="form-label">HTTP Methods</label>
|
|
541
|
+
<div className="flex gap-2">
|
|
542
|
+
${["get", "post", "put", "patch", "delete"].map(m => html`<button key=${m}
|
|
543
|
+
className=${`route-method ${m}`}
|
|
544
|
+
style=${{
|
|
545
|
+
cursor: existingMethods.includes(m) ? "not-allowed" : "pointer",
|
|
546
|
+
border: amMethods.includes(m) ? "2px solid " + amMethodColors[m] : "2px solid transparent",
|
|
547
|
+
padding: "6px 12px", fontSize: "11px",
|
|
548
|
+
opacity: existingMethods.includes(m) ? 0.15 : amMethods.includes(m) ? 1 : 0.35,
|
|
549
|
+
borderRadius: "6px", transition: "all 0.15s",
|
|
550
|
+
textDecoration: existingMethods.includes(m) ? "line-through" : "none",
|
|
551
|
+
}}
|
|
552
|
+
disabled=${existingMethods.includes(m)}
|
|
553
|
+
onClick=${() => !existingMethods.includes(m) && setAmMethods(prev => prev.includes(m) ? prev.filter(x => x !== m) : [...prev, m])}
|
|
554
|
+
>${m.toUpperCase()}</button>`)}
|
|
555
|
+
</div>
|
|
556
|
+
</div>`}
|
|
557
|
+
|
|
558
|
+
${amRouteType === "crud" && html`<div className="form-group">
|
|
559
|
+
<label className="form-label">CRUD Methods <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(select operations)</span></label>
|
|
560
|
+
<div className="flex gap-2">
|
|
561
|
+
${["get", "post", "put", "patch", "delete"].map(m => {
|
|
562
|
+
const labels = { get: "READ", post: "CREATE", put: "UPDATE", patch: "PATCH", delete: "DELETE" };
|
|
563
|
+
return html`<button key=${m} className=${`route-method ${m}`} style=${{
|
|
564
|
+
cursor: existingMethods.includes(m) ? "not-allowed" : "pointer",
|
|
565
|
+
border: amMethods.includes(m) ? "2px solid " + amMethodColors[m] : "2px solid transparent",
|
|
566
|
+
padding: "6px 12px", fontSize: "11px",
|
|
567
|
+
opacity: existingMethods.includes(m) ? 0.15 : amMethods.includes(m) ? 1 : 0.35,
|
|
568
|
+
borderRadius: "6px", transition: "all 0.15s",
|
|
569
|
+
textDecoration: existingMethods.includes(m) ? "line-through" : "none",
|
|
570
|
+
}} disabled=${existingMethods.includes(m)}
|
|
571
|
+
onClick=${() => !existingMethods.includes(m) && setAmMethods(prev => prev.includes(m) ? prev.filter(x => x !== m) : [...prev, m])}
|
|
572
|
+
>${labels[m]}</button>`;
|
|
573
|
+
})}
|
|
574
|
+
</div>
|
|
575
|
+
</div>`}
|
|
576
|
+
|
|
577
|
+
${amNeedsModel && html`<div className="form-group">
|
|
578
|
+
<label className="form-label">Model</label>
|
|
579
|
+
<select className="form-select" value=${amModelName} onChange=${(e) => { setAmModelName(e.target.value); setAmLoginField(""); setAmPasswordField(""); setAmRegisterFields([]); }}>
|
|
580
|
+
<option value="">Select a model...</option>
|
|
581
|
+
${(data.models || []).map(m => html`<option key=${m.name} value=${m.name}>${m.name}</option>`)}
|
|
582
|
+
</select>
|
|
583
|
+
</div>`}
|
|
584
|
+
|
|
585
|
+
${amRouteType === "methods" && html`<div className="form-group">
|
|
586
|
+
<label className="form-label">Fields from Model <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(optional)</span></label>
|
|
587
|
+
${data.models?.length > 0 ? html`<select className="form-select" style=${{ marginBottom: "8px" }} value=${amModelName}
|
|
588
|
+
onChange=${(e) => { setAmModelName(e.target.value); setAmSelectedFields([]); }}>
|
|
589
|
+
<option value="">Select a model...</option>
|
|
590
|
+
${data.models.map(m => html`<option key=${m.name} value=${m.name}>${m.name}</option>`)}
|
|
591
|
+
</select>
|
|
592
|
+
${amModelName && amModelFields.length > 0 && html`<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
593
|
+
${amModelFields.map(f => {
|
|
594
|
+
const active = amSelectedFields.includes(f.name);
|
|
595
|
+
return html`<button key=${f.name} style=${{
|
|
596
|
+
padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
|
|
597
|
+
border: active ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
|
|
598
|
+
background: active ? "var(--accent-glow)" : "var(--bg-input)",
|
|
599
|
+
color: active ? "var(--accent-light)" : "var(--text-secondary)",
|
|
600
|
+
fontWeight: active ? 600 : 400, transition: "all 0.15s",
|
|
601
|
+
}} onClick=${() => setAmSelectedFields(prev => active ? prev.filter(x => x !== f.name) : [...prev, f.name])}>
|
|
602
|
+
${f.name} <span style=${{ fontSize: "10px", color: "var(--text-muted)", marginLeft: "4px" }}>${f.type}</span>
|
|
603
|
+
</button>`;
|
|
604
|
+
})}
|
|
605
|
+
</div>`}` : html`<div style=${{ fontSize: "11px", color: "var(--text-muted)" }}>No models found.</div>`}
|
|
606
|
+
</div>`}
|
|
607
|
+
|
|
608
|
+
${amRouteType === "login" && amModelName && amModelFields.length > 0 && html`<div className="form-group">
|
|
609
|
+
<label className="form-label">Find user by</label>
|
|
610
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
611
|
+
${amModelFields.map(f => html`<button key=${f.name} style=${{
|
|
612
|
+
padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
|
|
613
|
+
border: amLoginField === f.name ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
|
|
614
|
+
background: amLoginField === f.name ? "var(--accent-glow)" : "var(--bg-input)",
|
|
615
|
+
color: amLoginField === f.name ? "var(--accent-light)" : "var(--text-secondary)",
|
|
616
|
+
fontWeight: amLoginField === f.name ? 600 : 400, transition: "all 0.15s",
|
|
617
|
+
}} onClick=${() => setAmLoginField(f.name)}>${f.name}</button>`)}
|
|
618
|
+
</div>
|
|
619
|
+
</div>`}
|
|
620
|
+
|
|
621
|
+
${amIsAuthType && amModelName && amModelFields.length > 0 && html`<div className="form-group">
|
|
622
|
+
<label className="form-label">Password field <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(optional — enables bcrypt)</span></label>
|
|
623
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
624
|
+
<button style=${{
|
|
625
|
+
padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
|
|
626
|
+
border: !amPasswordField ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
|
|
627
|
+
background: !amPasswordField ? "var(--accent-glow)" : "var(--bg-input)",
|
|
628
|
+
color: !amPasswordField ? "var(--accent-light)" : "var(--text-secondary)",
|
|
629
|
+
fontWeight: !amPasswordField ? 600 : 400, transition: "all 0.15s",
|
|
630
|
+
}} onClick=${() => setAmPasswordField("")}>None</button>
|
|
631
|
+
${amModelFields.map(f => html`<button key=${f.name} style=${{
|
|
632
|
+
padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
|
|
633
|
+
border: amPasswordField === f.name ? "1.5px solid var(--warning)" : "1.5px solid var(--border)",
|
|
634
|
+
background: amPasswordField === f.name ? "var(--warning-glow)" : "var(--bg-input)",
|
|
635
|
+
color: amPasswordField === f.name ? "var(--warning)" : "var(--text-secondary)",
|
|
636
|
+
fontWeight: amPasswordField === f.name ? 600 : 400, transition: "all 0.15s",
|
|
637
|
+
}} onClick=${() => setAmPasswordField(f.name)}>${f.name}</button>`)}
|
|
638
|
+
</div>
|
|
639
|
+
</div>`}
|
|
640
|
+
|
|
641
|
+
${amRouteType === "register" && amModelName && amModelFields.length > 0 && html`<div className="form-group">
|
|
642
|
+
<label className="form-label">Registration fields</label>
|
|
643
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
644
|
+
${amModelFields.map(f => {
|
|
645
|
+
const active = amRegisterFields.includes(f.name);
|
|
646
|
+
return html`<button key=${f.name} style=${{
|
|
647
|
+
padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
|
|
648
|
+
border: active ? "1.5px solid var(--success)" : "1.5px solid var(--border)",
|
|
649
|
+
background: active ? "var(--success-glow)" : "var(--bg-input)",
|
|
650
|
+
color: active ? "var(--success)" : "var(--text-secondary)",
|
|
651
|
+
fontWeight: active ? 600 : 400, transition: "all 0.15s",
|
|
652
|
+
}} onClick=${() => setAmRegisterFields(prev => active ? prev.filter(x => x !== f.name) : [...prev, f.name])}>${f.name}</button>`;
|
|
653
|
+
})}
|
|
654
|
+
</div>
|
|
655
|
+
</div>`}
|
|
656
|
+
|
|
657
|
+
${amRouteType === "register" && amModelName && amModelFields.length > 0 && html`<div className="form-group">
|
|
658
|
+
<label className="form-label">Check duplicate by</label>
|
|
659
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
660
|
+
${amModelFields.map(f => html`<button key=${f.name} style=${{
|
|
661
|
+
padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
|
|
662
|
+
border: amLoginField === f.name ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
|
|
663
|
+
background: amLoginField === f.name ? "var(--accent-glow)" : "var(--bg-input)",
|
|
664
|
+
color: amLoginField === f.name ? "var(--accent-light)" : "var(--text-secondary)",
|
|
665
|
+
fontWeight: amLoginField === f.name ? 600 : 400, transition: "all 0.15s",
|
|
666
|
+
}} onClick=${() => setAmLoginField(f.name)}>${f.name}</button>`)}
|
|
667
|
+
</div>
|
|
668
|
+
</div>`}
|
|
669
|
+
|
|
670
|
+
${amRouteType === "methods" && html`<div className="form-group">
|
|
671
|
+
<label className="form-label">Custom Fields <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(from client)</span></label>
|
|
672
|
+
${amCustomFields.length > 0 && html`<div style=${{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "8px" }}>
|
|
673
|
+
${amCustomFields.map((f, i) => html`<span key=${i} style=${{
|
|
674
|
+
padding: "4px 10px", borderRadius: "6px", fontSize: "12px",
|
|
675
|
+
background: "var(--bg-input)", border: "1.5px solid var(--border)",
|
|
676
|
+
color: "var(--text-primary)", display: "flex", alignItems: "center", gap: "6px",
|
|
677
|
+
}}>
|
|
678
|
+
${f.name} <span style=${{ fontSize: "10px", color: f.type === "File" ? "var(--warning)" : "var(--text-muted)" }}>${f.type}</span>
|
|
679
|
+
<span style=${{ cursor: "pointer", color: "var(--text-muted)", fontSize: "14px", lineHeight: 1 }}
|
|
680
|
+
onClick=${() => setAmCustomFields(prev => prev.filter((_, idx) => idx !== i))}>×</span>
|
|
681
|
+
</span>`)}
|
|
682
|
+
</div>`}
|
|
683
|
+
<div className="flex gap-2" style=${{ alignItems: "center" }}>
|
|
684
|
+
<input className="form-input" value=${amNewFieldName}
|
|
685
|
+
onInput=${(e) => setAmNewFieldName(e.target.value)} placeholder="Field name"
|
|
686
|
+
style=${{ fontSize: "13px", padding: "8px 12px", flex: 1 }}
|
|
687
|
+
onKeyDown=${(e) => { if (e.key === "Enter" && amNewFieldName.trim()) { setAmCustomFields(prev => [...prev, { name: amNewFieldName.trim(), type: amNewFieldType }]); setAmNewFieldName(""); }}} />
|
|
688
|
+
<select className="form-select" value=${amNewFieldType} onChange=${(e) => setAmNewFieldType(e.target.value)} style=${{ width: "auto", minWidth: "100px" }}>
|
|
689
|
+
<option value="String">String</option><option value="Number">Number</option><option value="Boolean">Boolean</option>
|
|
690
|
+
<option value="Date">Date</option><option value="Array">Array</option><option value="Object">Object</option><option value="File">File</option>
|
|
691
|
+
</select>
|
|
692
|
+
<button className="btn btn-sm" style=${{
|
|
693
|
+
padding: "8px 14px", fontSize: "12px", background: "var(--bg-input)",
|
|
694
|
+
border: "1.5px solid var(--border)", color: "var(--text-primary)",
|
|
695
|
+
cursor: amNewFieldName.trim() ? "pointer" : "not-allowed",
|
|
696
|
+
opacity: amNewFieldName.trim() ? 1 : 0.4, borderRadius: "6px",
|
|
697
|
+
}} disabled=${!amNewFieldName.trim()} onClick=${() => { if (amNewFieldName.trim()) { setAmCustomFields(prev => [...prev, { name: amNewFieldName.trim(), type: amNewFieldType }]); setAmNewFieldName(""); } }}>+ Add</button>
|
|
698
|
+
</div>
|
|
699
|
+
</div>`}
|
|
700
|
+
|
|
701
|
+
${amCanSubmit && html`<button className="btn btn-primary" disabled=${amLoading}
|
|
702
|
+
onClick=${async () => {
|
|
703
|
+
setAmLoading(true);
|
|
704
|
+
try {
|
|
705
|
+
const result = await api("/routes/" + selectedRoute + "/add-method", {
|
|
706
|
+
method: "POST",
|
|
707
|
+
body: {
|
|
708
|
+
routeType: amRouteType,
|
|
709
|
+
methods: (amRouteType === "methods" || amRouteType === "crud") ? amMethods : undefined,
|
|
710
|
+
modelName: amModelName || undefined,
|
|
711
|
+
loginField: amIsAuthType ? amLoginField : undefined,
|
|
712
|
+
passwordField: amIsAuthType && amPasswordField ? amPasswordField : undefined,
|
|
713
|
+
registerFields: amRouteType === "register" ? amRegisterFields : undefined,
|
|
714
|
+
selectedFields: amRouteType === "methods" && amSelectedFields.length > 0 ? amSelectedFields : undefined,
|
|
715
|
+
customFields: amRouteType === "methods" && amCustomFields.length > 0 ? amCustomFields : undefined,
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
toast(result.message, "success");
|
|
719
|
+
setRouteContents(prev => ({ ...prev, [selectedRoute]: { content: result.content } }));
|
|
720
|
+
setShowAddMethod(false);
|
|
721
|
+
refresh();
|
|
722
|
+
} catch (err) { toast(err.message, "error"); }
|
|
723
|
+
setAmLoading(false);
|
|
724
|
+
}}
|
|
725
|
+
>${amLoading ? html`<span className="spinner" />` : "Add to Route"}</button>`}
|
|
726
|
+
</div>`;
|
|
727
|
+
})()}
|
|
728
|
+
|
|
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
|
+
<div
|
|
785
|
+
className="card"
|
|
786
|
+
style=${{ padding: 0, overflow: "hidden" }}
|
|
787
|
+
>
|
|
788
|
+
<div
|
|
789
|
+
style=${{
|
|
790
|
+
padding: "12px 16px",
|
|
791
|
+
borderBottom: "1px solid var(--border)",
|
|
792
|
+
display: "flex",
|
|
793
|
+
alignItems: "center",
|
|
794
|
+
justifyContent: "space-between",
|
|
795
|
+
}}
|
|
796
|
+
>
|
|
797
|
+
<span
|
|
798
|
+
style=${{
|
|
799
|
+
fontSize: "12px",
|
|
800
|
+
fontWeight: 600,
|
|
801
|
+
color: "var(--text-secondary)",
|
|
802
|
+
}}
|
|
803
|
+
>${editingRoute
|
|
804
|
+
? "Edit Source Code"
|
|
805
|
+
: "Source Code"}</span
|
|
806
|
+
>
|
|
807
|
+
<span className="field-count"
|
|
808
|
+
>${selContent
|
|
809
|
+
? selContent.split("\\n").length + " lines"
|
|
810
|
+
: ""}</span
|
|
811
|
+
>
|
|
812
|
+
</div>
|
|
813
|
+
${loadingRoute === selectedRoute &&
|
|
814
|
+
html`<div style=${{ padding: "30px", textAlign: "center" }}>
|
|
815
|
+
<span className="spinner" />
|
|
816
|
+
</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 &&
|
|
856
|
+
html`<div className="route-code-wrap">
|
|
857
|
+
<div className="code-block">${selContent}</div>
|
|
858
|
+
</div>`}
|
|
859
|
+
</div>
|
|
860
|
+
</div>`}
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
</div>`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function CreateRoutePanel({ onCreated }) {
|
|
867
|
+
const { data, toast } = useApp();
|
|
868
|
+
const [name, setName] = useState("");
|
|
869
|
+
const [routeType, setRouteType] = useState(null);
|
|
870
|
+
const [methods, setMethods] = useState([]);
|
|
871
|
+
const pathPrefix = "/api/";
|
|
872
|
+
const [modelName, setModelName] = useState("");
|
|
873
|
+
const [loginField, setLoginField] = useState("");
|
|
874
|
+
const [passwordField, setPasswordField] = useState("");
|
|
875
|
+
const [registerFields, setRegisterFields] = useState([]);
|
|
876
|
+
const [selectedFields, setSelectedFields] = useState([]);
|
|
877
|
+
const [customFields, setCustomFields] = useState([]);
|
|
878
|
+
const [newFieldName, setNewFieldName] = useState("");
|
|
879
|
+
const [newFieldType, setNewFieldType] = useState("String");
|
|
880
|
+
const [loading, setLoading] = useState(false);
|
|
881
|
+
const nameRef = useRef(null);
|
|
882
|
+
|
|
883
|
+
useEffect(() => {
|
|
884
|
+
nameRef.current?.focus();
|
|
885
|
+
}, []);
|
|
886
|
+
|
|
887
|
+
const typeOptions = [
|
|
888
|
+
{
|
|
889
|
+
id: "plain",
|
|
890
|
+
label: "Plain",
|
|
891
|
+
desc: "Empty route",
|
|
892
|
+
color: "#94a3b8",
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
id: "methods",
|
|
896
|
+
label: "Methods",
|
|
897
|
+
desc: "GET, POST, PUT...",
|
|
898
|
+
color: "var(--accent-light)",
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
id: "crud",
|
|
902
|
+
label: "CRUD",
|
|
903
|
+
desc: "Model CRUD ops",
|
|
904
|
+
color: "#3b82f6",
|
|
905
|
+
},
|
|
906
|
+
{ id: "login", label: "Login", desc: "JWT login", color: "#22d3ee" },
|
|
907
|
+
{
|
|
908
|
+
id: "register",
|
|
909
|
+
label: "Register",
|
|
910
|
+
desc: "Sign up",
|
|
911
|
+
color: "#34d399",
|
|
912
|
+
},
|
|
913
|
+
];
|
|
914
|
+
|
|
915
|
+
function selectType(id) {
|
|
916
|
+
setRouteType((prev) => (prev === id ? null : id));
|
|
917
|
+
if (id !== "methods" && id !== "crud") setMethods([]);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function toggleMethod(m) {
|
|
921
|
+
setMethods((prev) =>
|
|
922
|
+
prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m],
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const needsModel =
|
|
927
|
+
routeType && routeType !== "methods" && routeType !== "plain";
|
|
928
|
+
const isAuthType = routeType === "login" || routeType === "register";
|
|
929
|
+
|
|
930
|
+
// Get fields from selected model
|
|
931
|
+
const selectedModelObj = data.models?.find((m) => m.name === modelName);
|
|
932
|
+
const modelFields = selectedModelObj?.fields || [];
|
|
933
|
+
|
|
934
|
+
// Auto-select fields when model changes
|
|
935
|
+
useEffect(() => {
|
|
936
|
+
if (modelFields.length > 0 && isAuthType) {
|
|
937
|
+
if (!loginField) {
|
|
938
|
+
const emailField =
|
|
939
|
+
modelFields.find((f) => f.name === "email") || modelFields[0];
|
|
940
|
+
setLoginField(emailField?.name || "");
|
|
941
|
+
}
|
|
942
|
+
if (!passwordField) {
|
|
943
|
+
const pwField = modelFields.find((f) => f.name === "password");
|
|
944
|
+
setPasswordField(pwField?.name || "");
|
|
945
|
+
}
|
|
946
|
+
if (routeType === "register" && registerFields.length === 0) {
|
|
947
|
+
setRegisterFields(modelFields.map((f) => f.name));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}, [modelName, routeType]);
|
|
951
|
+
|
|
952
|
+
async function handleCreate() {
|
|
953
|
+
if (!name.trim() || !routeType) return;
|
|
954
|
+
if (routeType === "methods" && methods.length === 0) {
|
|
955
|
+
toast("Select at least one method", "error");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (routeType === "crud" && methods.length === 0) {
|
|
959
|
+
toast("Select at least one CRUD method", "error");
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (needsModel && !modelName) {
|
|
963
|
+
toast("Select a model", "error");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (isAuthType && !loginField) {
|
|
967
|
+
toast("Select a login field", "error");
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
setLoading(true);
|
|
971
|
+
try {
|
|
972
|
+
const result = await api("/add-api", {
|
|
973
|
+
method: "POST",
|
|
974
|
+
body: {
|
|
975
|
+
name: name.trim(),
|
|
976
|
+
routeType,
|
|
977
|
+
pathPrefix,
|
|
978
|
+
methods:
|
|
979
|
+
routeType === "methods" || routeType === "crud"
|
|
980
|
+
? methods
|
|
981
|
+
: undefined,
|
|
982
|
+
modelName: modelName || undefined,
|
|
983
|
+
loginField: isAuthType ? loginField : undefined,
|
|
984
|
+
passwordField:
|
|
985
|
+
isAuthType && passwordField ? passwordField : undefined,
|
|
986
|
+
registerFields:
|
|
987
|
+
routeType === "register" ? registerFields : undefined,
|
|
988
|
+
selectedFields:
|
|
989
|
+
routeType === "methods" && selectedFields.length > 0
|
|
990
|
+
? selectedFields
|
|
991
|
+
: undefined,
|
|
992
|
+
customFields:
|
|
993
|
+
routeType === "methods" && customFields.length > 0
|
|
994
|
+
? customFields
|
|
995
|
+
: undefined,
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
toast(result.message, "success");
|
|
999
|
+
onCreated();
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
toast(err.message, "error");
|
|
1002
|
+
}
|
|
1003
|
+
setLoading(false);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const methodColors = {
|
|
1007
|
+
get: "#22c55e",
|
|
1008
|
+
post: "#3b82f6",
|
|
1009
|
+
put: "#f59e0b",
|
|
1010
|
+
patch: "#a855f7",
|
|
1011
|
+
delete: "#ef4444",
|
|
1012
|
+
};
|
|
1013
|
+
const canCreate =
|
|
1014
|
+
name.trim() &&
|
|
1015
|
+
routeType &&
|
|
1016
|
+
(routeType !== "methods" || methods.length > 0) &&
|
|
1017
|
+
(routeType !== "crud" || methods.length > 0) &&
|
|
1018
|
+
(!needsModel || modelName) &&
|
|
1019
|
+
(!isAuthType || loginField);
|
|
1020
|
+
|
|
1021
|
+
return html`<div>
|
|
1022
|
+
<div className="card mb-4">
|
|
1023
|
+
<div
|
|
1024
|
+
className="flex items-center justify-between"
|
|
1025
|
+
style=${{ marginBottom: "16px" }}
|
|
1026
|
+
>
|
|
1027
|
+
<div style=${{ fontSize: "15px", fontWeight: 600 }}>
|
|
1028
|
+
Create New Route
|
|
1029
|
+
</div>
|
|
1030
|
+
<button
|
|
1031
|
+
className="btn btn-primary btn-sm"
|
|
1032
|
+
onClick=${handleCreate}
|
|
1033
|
+
disabled=${loading || !canCreate}
|
|
1034
|
+
>
|
|
1035
|
+
${loading
|
|
1036
|
+
? html`<span className="spinner" />`
|
|
1037
|
+
: html`<span className="sidebar-icon">${Icons.plus}</span>`}
|
|
1038
|
+
${loading ? "Creating..." : "Create Route"}
|
|
1039
|
+
</button>
|
|
1040
|
+
</div>
|
|
1041
|
+
|
|
1042
|
+
<div className="form-group">
|
|
1043
|
+
<label className="form-label">Route Name</label>
|
|
1044
|
+
<input
|
|
1045
|
+
ref=${nameRef}
|
|
1046
|
+
className="form-input"
|
|
1047
|
+
value=${name}
|
|
1048
|
+
onInput=${(e) => setName(e.target.value)}
|
|
1049
|
+
onKeyDown=${(e) =>
|
|
1050
|
+
e.key === "Enter" && canCreate && handleCreate()}
|
|
1051
|
+
placeholder="e.g. users, login, products"
|
|
1052
|
+
style=${{ fontSize: "14px", padding: "11px 14px" }}
|
|
1053
|
+
/>
|
|
1054
|
+
</div>
|
|
1055
|
+
|
|
1056
|
+
<div className="form-group">
|
|
1057
|
+
<label className="form-label">Type</label>
|
|
1058
|
+
<div
|
|
1059
|
+
style=${{
|
|
1060
|
+
display: "grid",
|
|
1061
|
+
gridTemplateColumns: "repeat(5, 1fr)",
|
|
1062
|
+
gap: "6px",
|
|
1063
|
+
}}
|
|
1064
|
+
>
|
|
1065
|
+
${typeOptions.map((t) => {
|
|
1066
|
+
const active = routeType === t.id;
|
|
1067
|
+
return html`<button
|
|
1068
|
+
key=${t.id}
|
|
1069
|
+
style=${{
|
|
1070
|
+
padding: "8px 4px",
|
|
1071
|
+
borderRadius: "var(--radius)",
|
|
1072
|
+
cursor: "pointer",
|
|
1073
|
+
textAlign: "center",
|
|
1074
|
+
border: active
|
|
1075
|
+
? "1.5px solid " + t.color
|
|
1076
|
+
: "1.5px solid var(--border)",
|
|
1077
|
+
background: active
|
|
1078
|
+
? "rgba(255,255,255,0.04)"
|
|
1079
|
+
: "var(--bg-input)",
|
|
1080
|
+
opacity: active ? 1 : 0.5,
|
|
1081
|
+
transition: "all 0.15s",
|
|
1082
|
+
}}
|
|
1083
|
+
onClick=${() => selectType(t.id)}
|
|
1084
|
+
>
|
|
1085
|
+
<div
|
|
1086
|
+
style=${{
|
|
1087
|
+
fontSize: "11px",
|
|
1088
|
+
fontWeight: 700,
|
|
1089
|
+
color: active ? t.color : "var(--text-secondary)",
|
|
1090
|
+
}}
|
|
1091
|
+
>
|
|
1092
|
+
${t.label}
|
|
1093
|
+
</div>
|
|
1094
|
+
<div
|
|
1095
|
+
style=${{
|
|
1096
|
+
fontSize: "9px",
|
|
1097
|
+
color: "var(--text-muted)",
|
|
1098
|
+
marginTop: "1px",
|
|
1099
|
+
}}
|
|
1100
|
+
>
|
|
1101
|
+
${t.desc}
|
|
1102
|
+
</div>
|
|
1103
|
+
</button>`;
|
|
1104
|
+
})}
|
|
1105
|
+
</div>
|
|
1106
|
+
</div>
|
|
1107
|
+
|
|
1108
|
+
${routeType === "methods" &&
|
|
1109
|
+
html`<div className="form-group">
|
|
1110
|
+
<label className="form-label">HTTP Methods</label>
|
|
1111
|
+
<div className="flex gap-2">
|
|
1112
|
+
${["get", "post", "put", "patch", "delete"].map(
|
|
1113
|
+
(m) =>
|
|
1114
|
+
html`<button
|
|
1115
|
+
key=${m}
|
|
1116
|
+
className=${`route-method ${m}`}
|
|
1117
|
+
style=${{
|
|
1118
|
+
cursor: "pointer",
|
|
1119
|
+
border: methods.includes(m)
|
|
1120
|
+
? "2px solid " + methodColors[m]
|
|
1121
|
+
: "2px solid transparent",
|
|
1122
|
+
padding: "6px 12px",
|
|
1123
|
+
fontSize: "11px",
|
|
1124
|
+
opacity: methods.includes(m) ? 1 : 0.35,
|
|
1125
|
+
borderRadius: "6px",
|
|
1126
|
+
transition: "all 0.15s",
|
|
1127
|
+
}}
|
|
1128
|
+
onClick=${() => toggleMethod(m)}
|
|
1129
|
+
>
|
|
1130
|
+
${m.toUpperCase()}
|
|
1131
|
+
</button>`,
|
|
1132
|
+
)}
|
|
1133
|
+
</div>
|
|
1134
|
+
</div>`}
|
|
1135
|
+
${routeType === "methods" &&
|
|
1136
|
+
html`<div className="form-group">
|
|
1137
|
+
<label className="form-label"
|
|
1138
|
+
>Fields from Model
|
|
1139
|
+
<span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
|
|
1140
|
+
>(optional — select fields to destructure from
|
|
1141
|
+
request)</span
|
|
1142
|
+
></label
|
|
1143
|
+
>
|
|
1144
|
+
${data.models?.length > 0
|
|
1145
|
+
? html`<select
|
|
1146
|
+
className="form-select"
|
|
1147
|
+
style=${{ marginBottom: "8px" }}
|
|
1148
|
+
value=${modelName}
|
|
1149
|
+
onChange=${(e) => {
|
|
1150
|
+
setModelName(e.target.value);
|
|
1151
|
+
setSelectedFields([]);
|
|
1152
|
+
}}
|
|
1153
|
+
>
|
|
1154
|
+
<option value="">Select a model...</option>
|
|
1155
|
+
${data.models.map(
|
|
1156
|
+
(m) =>
|
|
1157
|
+
html`<option key=${m.name} value=${m.name}>
|
|
1158
|
+
${m.name}
|
|
1159
|
+
</option>`,
|
|
1160
|
+
)}
|
|
1161
|
+
</select>
|
|
1162
|
+
${modelName &&
|
|
1163
|
+
modelFields.length > 0 &&
|
|
1164
|
+
html`<div
|
|
1165
|
+
className="flex gap-2"
|
|
1166
|
+
style=${{ flexWrap: "wrap" }}
|
|
1167
|
+
>
|
|
1168
|
+
${modelFields.map((f) => {
|
|
1169
|
+
const active = selectedFields.includes(f.name);
|
|
1170
|
+
return html`<button
|
|
1171
|
+
key=${f.name}
|
|
1172
|
+
style=${{
|
|
1173
|
+
padding: "5px 12px",
|
|
1174
|
+
borderRadius: "6px",
|
|
1175
|
+
fontSize: "12px",
|
|
1176
|
+
cursor: "pointer",
|
|
1177
|
+
border: active
|
|
1178
|
+
? "1.5px solid var(--accent)"
|
|
1179
|
+
: "1.5px solid var(--border)",
|
|
1180
|
+
background: active
|
|
1181
|
+
? "var(--accent-glow)"
|
|
1182
|
+
: "var(--bg-input)",
|
|
1183
|
+
color: active
|
|
1184
|
+
? "var(--accent-light)"
|
|
1185
|
+
: "var(--text-secondary)",
|
|
1186
|
+
fontWeight: active ? 600 : 400,
|
|
1187
|
+
transition: "all 0.15s",
|
|
1188
|
+
}}
|
|
1189
|
+
onClick=${() =>
|
|
1190
|
+
setSelectedFields((prev) =>
|
|
1191
|
+
active
|
|
1192
|
+
? prev.filter((x) => x !== f.name)
|
|
1193
|
+
: [...prev, f.name],
|
|
1194
|
+
)}
|
|
1195
|
+
>
|
|
1196
|
+
${f.name}
|
|
1197
|
+
<span
|
|
1198
|
+
style=${{
|
|
1199
|
+
fontSize: "10px",
|
|
1200
|
+
color: "var(--text-muted)",
|
|
1201
|
+
marginLeft: "4px",
|
|
1202
|
+
}}
|
|
1203
|
+
>${f.type}</span
|
|
1204
|
+
>
|
|
1205
|
+
</button>`;
|
|
1206
|
+
})}
|
|
1207
|
+
</div>`}`
|
|
1208
|
+
: html`<div
|
|
1209
|
+
style=${{
|
|
1210
|
+
fontSize: "11px",
|
|
1211
|
+
color: "var(--text-muted)",
|
|
1212
|
+
marginTop: "4px",
|
|
1213
|
+
}}
|
|
1214
|
+
>
|
|
1215
|
+
No models found. You can still add custom fields below.
|
|
1216
|
+
</div>`}
|
|
1217
|
+
</div>`}
|
|
1218
|
+
${routeType === "methods" &&
|
|
1219
|
+
html`<div className="form-group">
|
|
1220
|
+
<label className="form-label"
|
|
1221
|
+
>Custom Fields
|
|
1222
|
+
<span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
|
|
1223
|
+
>(add fields received from client)</span
|
|
1224
|
+
></label
|
|
1225
|
+
>
|
|
1226
|
+
${customFields.length > 0 &&
|
|
1227
|
+
html`<div
|
|
1228
|
+
style=${{
|
|
1229
|
+
display: "flex",
|
|
1230
|
+
flexWrap: "wrap",
|
|
1231
|
+
gap: "6px",
|
|
1232
|
+
marginBottom: "8px",
|
|
1233
|
+
}}
|
|
1234
|
+
>
|
|
1235
|
+
${customFields.map(
|
|
1236
|
+
(f, i) =>
|
|
1237
|
+
html`<span
|
|
1238
|
+
key=${i}
|
|
1239
|
+
style=${{
|
|
1240
|
+
padding: "4px 10px",
|
|
1241
|
+
borderRadius: "6px",
|
|
1242
|
+
fontSize: "12px",
|
|
1243
|
+
background: "var(--bg-input)",
|
|
1244
|
+
border: "1.5px solid var(--border)",
|
|
1245
|
+
color: "var(--text-primary)",
|
|
1246
|
+
display: "flex",
|
|
1247
|
+
alignItems: "center",
|
|
1248
|
+
gap: "6px",
|
|
1249
|
+
}}
|
|
1250
|
+
>
|
|
1251
|
+
${f.name}
|
|
1252
|
+
<span
|
|
1253
|
+
style=${{
|
|
1254
|
+
fontSize: "10px",
|
|
1255
|
+
color:
|
|
1256
|
+
f.type === "File"
|
|
1257
|
+
? "var(--warning)"
|
|
1258
|
+
: "var(--text-muted)",
|
|
1259
|
+
}}
|
|
1260
|
+
>${f.type}</span
|
|
1261
|
+
>
|
|
1262
|
+
<span
|
|
1263
|
+
style=${{
|
|
1264
|
+
cursor: "pointer",
|
|
1265
|
+
color: "var(--text-muted)",
|
|
1266
|
+
fontSize: "14px",
|
|
1267
|
+
lineHeight: 1,
|
|
1268
|
+
marginLeft: "2px",
|
|
1269
|
+
}}
|
|
1270
|
+
onClick=${() =>
|
|
1271
|
+
setCustomFields((prev) =>
|
|
1272
|
+
prev.filter((_, idx) => idx !== i),
|
|
1273
|
+
)}
|
|
1274
|
+
>×</span
|
|
1275
|
+
>
|
|
1276
|
+
</span>`,
|
|
1277
|
+
)}
|
|
1278
|
+
</div>`}
|
|
1279
|
+
<div className="flex gap-2" style=${{ alignItems: "center" }}>
|
|
1280
|
+
<input
|
|
1281
|
+
className="form-input"
|
|
1282
|
+
value=${newFieldName}
|
|
1283
|
+
onInput=${(e) => setNewFieldName(e.target.value)}
|
|
1284
|
+
placeholder="Field name"
|
|
1285
|
+
style=${{
|
|
1286
|
+
fontSize: "13px",
|
|
1287
|
+
padding: "8px 12px",
|
|
1288
|
+
flex: 1,
|
|
1289
|
+
}}
|
|
1290
|
+
onKeyDown=${(e) => {
|
|
1291
|
+
if (e.key === "Enter" && newFieldName.trim()) {
|
|
1292
|
+
setCustomFields((prev) => [
|
|
1293
|
+
...prev,
|
|
1294
|
+
{ name: newFieldName.trim(), type: newFieldType },
|
|
1295
|
+
]);
|
|
1296
|
+
setNewFieldName("");
|
|
1297
|
+
}
|
|
1298
|
+
}}
|
|
1299
|
+
/>
|
|
1300
|
+
<select
|
|
1301
|
+
className="form-select"
|
|
1302
|
+
value=${newFieldType}
|
|
1303
|
+
onChange=${(e) => setNewFieldType(e.target.value)}
|
|
1304
|
+
style=${{ width: "auto", minWidth: "100px" }}
|
|
1305
|
+
>
|
|
1306
|
+
<option value="String">String</option>
|
|
1307
|
+
<option value="Number">Number</option>
|
|
1308
|
+
<option value="Boolean">Boolean</option>
|
|
1309
|
+
<option value="Date">Date</option>
|
|
1310
|
+
<option value="Array">Array</option>
|
|
1311
|
+
<option value="Object">Object</option>
|
|
1312
|
+
<option value="File">File</option>
|
|
1313
|
+
</select>
|
|
1314
|
+
<button
|
|
1315
|
+
className="btn btn-sm"
|
|
1316
|
+
style=${{
|
|
1317
|
+
padding: "8px 14px",
|
|
1318
|
+
fontSize: "12px",
|
|
1319
|
+
background: "var(--bg-input)",
|
|
1320
|
+
border: "1.5px solid var(--border)",
|
|
1321
|
+
color: "var(--text-primary)",
|
|
1322
|
+
cursor: newFieldName.trim() ? "pointer" : "not-allowed",
|
|
1323
|
+
opacity: newFieldName.trim() ? 1 : 0.4,
|
|
1324
|
+
borderRadius: "6px",
|
|
1325
|
+
}}
|
|
1326
|
+
disabled=${!newFieldName.trim()}
|
|
1327
|
+
onClick=${() => {
|
|
1328
|
+
if (newFieldName.trim()) {
|
|
1329
|
+
setCustomFields((prev) => [
|
|
1330
|
+
...prev,
|
|
1331
|
+
{ name: newFieldName.trim(), type: newFieldType },
|
|
1332
|
+
]);
|
|
1333
|
+
setNewFieldName("");
|
|
1334
|
+
}
|
|
1335
|
+
}}
|
|
1336
|
+
>
|
|
1337
|
+
+ Add
|
|
1338
|
+
</button>
|
|
1339
|
+
</div>
|
|
1340
|
+
</div>`}
|
|
1341
|
+
${routeType === "crud" &&
|
|
1342
|
+
html`<div className="form-group">
|
|
1343
|
+
<label className="form-label"
|
|
1344
|
+
>CRUD Methods
|
|
1345
|
+
<span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
|
|
1346
|
+
>(select which operations to include)</span
|
|
1347
|
+
></label
|
|
1348
|
+
>
|
|
1349
|
+
<div className="flex gap-2">
|
|
1350
|
+
${["get", "post", "put", "patch", "delete"].map((m) => {
|
|
1351
|
+
const labels = {
|
|
1352
|
+
get: "READ",
|
|
1353
|
+
post: "CREATE",
|
|
1354
|
+
put: "UPDATE",
|
|
1355
|
+
patch: "PATCH",
|
|
1356
|
+
delete: "DELETE",
|
|
1357
|
+
};
|
|
1358
|
+
return html`<button
|
|
1359
|
+
key=${m}
|
|
1360
|
+
className=${`route-method ${m}`}
|
|
1361
|
+
style=${{
|
|
1362
|
+
cursor: "pointer",
|
|
1363
|
+
border: methods.includes(m)
|
|
1364
|
+
? "2px solid " + methodColors[m]
|
|
1365
|
+
: "2px solid transparent",
|
|
1366
|
+
padding: "6px 12px",
|
|
1367
|
+
fontSize: "11px",
|
|
1368
|
+
opacity: methods.includes(m) ? 1 : 0.35,
|
|
1369
|
+
borderRadius: "6px",
|
|
1370
|
+
transition: "all 0.15s",
|
|
1371
|
+
}}
|
|
1372
|
+
onClick=${() => toggleMethod(m)}
|
|
1373
|
+
>
|
|
1374
|
+
${labels[m]}
|
|
1375
|
+
</button>`;
|
|
1376
|
+
})}
|
|
1377
|
+
</div>
|
|
1378
|
+
</div>`}
|
|
1379
|
+
${needsModel &&
|
|
1380
|
+
html`<div className="form-group">
|
|
1381
|
+
<label className="form-label">Model</label>
|
|
1382
|
+
<select
|
|
1383
|
+
className="form-select"
|
|
1384
|
+
value=${modelName}
|
|
1385
|
+
onChange=${(e) => {
|
|
1386
|
+
setModelName(e.target.value);
|
|
1387
|
+
setLoginField("");
|
|
1388
|
+
setPasswordField("");
|
|
1389
|
+
setRegisterFields([]);
|
|
1390
|
+
}}
|
|
1391
|
+
>
|
|
1392
|
+
<option value="">Select a model...</option>
|
|
1393
|
+
${(data.models || []).map(
|
|
1394
|
+
(m) =>
|
|
1395
|
+
html`<option key=${m.name} value=${m.name}>
|
|
1396
|
+
${m.name}
|
|
1397
|
+
</option>`,
|
|
1398
|
+
)}
|
|
1399
|
+
</select>
|
|
1400
|
+
${!data.models?.length &&
|
|
1401
|
+
html`<div
|
|
1402
|
+
style=${{
|
|
1403
|
+
fontSize: "11px",
|
|
1404
|
+
color: "var(--warning)",
|
|
1405
|
+
marginTop: "4px",
|
|
1406
|
+
}}
|
|
1407
|
+
>
|
|
1408
|
+
No models found. Create a schema first.
|
|
1409
|
+
</div>`}
|
|
1410
|
+
</div>`}
|
|
1411
|
+
${routeType === "login" &&
|
|
1412
|
+
modelName &&
|
|
1413
|
+
modelFields.length > 0 &&
|
|
1414
|
+
html`<div className="form-group">
|
|
1415
|
+
<label className="form-label">Find user by</label>
|
|
1416
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
1417
|
+
${modelFields.map(
|
|
1418
|
+
(f) =>
|
|
1419
|
+
html`<button
|
|
1420
|
+
key=${f.name}
|
|
1421
|
+
style=${{
|
|
1422
|
+
padding: "5px 12px",
|
|
1423
|
+
borderRadius: "6px",
|
|
1424
|
+
fontSize: "12px",
|
|
1425
|
+
cursor: "pointer",
|
|
1426
|
+
border:
|
|
1427
|
+
loginField === f.name
|
|
1428
|
+
? "1.5px solid var(--accent)"
|
|
1429
|
+
: "1.5px solid var(--border)",
|
|
1430
|
+
background:
|
|
1431
|
+
loginField === f.name
|
|
1432
|
+
? "var(--accent-glow)"
|
|
1433
|
+
: "var(--bg-input)",
|
|
1434
|
+
color:
|
|
1435
|
+
loginField === f.name
|
|
1436
|
+
? "var(--accent-light)"
|
|
1437
|
+
: "var(--text-secondary)",
|
|
1438
|
+
fontWeight: loginField === f.name ? 600 : 400,
|
|
1439
|
+
transition: "all 0.15s",
|
|
1440
|
+
}}
|
|
1441
|
+
onClick=${() => setLoginField(f.name)}
|
|
1442
|
+
>
|
|
1443
|
+
${f.name}
|
|
1444
|
+
</button>`,
|
|
1445
|
+
)}
|
|
1446
|
+
</div>
|
|
1447
|
+
</div>`}
|
|
1448
|
+
${routeType === "login" &&
|
|
1449
|
+
modelName &&
|
|
1450
|
+
modelFields.length > 0 &&
|
|
1451
|
+
html`<div className="form-group">
|
|
1452
|
+
<label className="form-label"
|
|
1453
|
+
>Password field
|
|
1454
|
+
<span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
|
|
1455
|
+
>(optional — enables bcrypt)</span
|
|
1456
|
+
></label
|
|
1457
|
+
>
|
|
1458
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
1459
|
+
<button
|
|
1460
|
+
style=${{
|
|
1461
|
+
padding: "5px 12px",
|
|
1462
|
+
borderRadius: "6px",
|
|
1463
|
+
fontSize: "12px",
|
|
1464
|
+
cursor: "pointer",
|
|
1465
|
+
border: !passwordField
|
|
1466
|
+
? "1.5px solid var(--accent)"
|
|
1467
|
+
: "1.5px solid var(--border)",
|
|
1468
|
+
background: !passwordField
|
|
1469
|
+
? "var(--accent-glow)"
|
|
1470
|
+
: "var(--bg-input)",
|
|
1471
|
+
color: !passwordField
|
|
1472
|
+
? "var(--accent-light)"
|
|
1473
|
+
: "var(--text-secondary)",
|
|
1474
|
+
fontWeight: !passwordField ? 600 : 400,
|
|
1475
|
+
transition: "all 0.15s",
|
|
1476
|
+
}}
|
|
1477
|
+
onClick=${() => setPasswordField("")}
|
|
1478
|
+
>
|
|
1479
|
+
None
|
|
1480
|
+
</button>
|
|
1481
|
+
${modelFields.map(
|
|
1482
|
+
(f) =>
|
|
1483
|
+
html`<button
|
|
1484
|
+
key=${f.name}
|
|
1485
|
+
style=${{
|
|
1486
|
+
padding: "5px 12px",
|
|
1487
|
+
borderRadius: "6px",
|
|
1488
|
+
fontSize: "12px",
|
|
1489
|
+
cursor: "pointer",
|
|
1490
|
+
border:
|
|
1491
|
+
passwordField === f.name
|
|
1492
|
+
? "1.5px solid var(--warning)"
|
|
1493
|
+
: "1.5px solid var(--border)",
|
|
1494
|
+
background:
|
|
1495
|
+
passwordField === f.name
|
|
1496
|
+
? "var(--warning-glow)"
|
|
1497
|
+
: "var(--bg-input)",
|
|
1498
|
+
color:
|
|
1499
|
+
passwordField === f.name
|
|
1500
|
+
? "var(--warning)"
|
|
1501
|
+
: "var(--text-secondary)",
|
|
1502
|
+
fontWeight: passwordField === f.name ? 600 : 400,
|
|
1503
|
+
transition: "all 0.15s",
|
|
1504
|
+
}}
|
|
1505
|
+
onClick=${() => setPasswordField(f.name)}
|
|
1506
|
+
>
|
|
1507
|
+
${f.name}
|
|
1508
|
+
</button>`,
|
|
1509
|
+
)}
|
|
1510
|
+
</div>
|
|
1511
|
+
</div>`}
|
|
1512
|
+
${routeType === "register" &&
|
|
1513
|
+
modelName &&
|
|
1514
|
+
modelFields.length > 0 &&
|
|
1515
|
+
html`<div className="form-group">
|
|
1516
|
+
<label className="form-label">Registration fields</label>
|
|
1517
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
1518
|
+
${modelFields.map((f) => {
|
|
1519
|
+
const active = registerFields.includes(f.name);
|
|
1520
|
+
return html`<button
|
|
1521
|
+
key=${f.name}
|
|
1522
|
+
style=${{
|
|
1523
|
+
padding: "5px 12px",
|
|
1524
|
+
borderRadius: "6px",
|
|
1525
|
+
fontSize: "12px",
|
|
1526
|
+
cursor: "pointer",
|
|
1527
|
+
border: active
|
|
1528
|
+
? "1.5px solid var(--success)"
|
|
1529
|
+
: "1.5px solid var(--border)",
|
|
1530
|
+
background: active
|
|
1531
|
+
? "var(--success-glow)"
|
|
1532
|
+
: "var(--bg-input)",
|
|
1533
|
+
color: active
|
|
1534
|
+
? "var(--success)"
|
|
1535
|
+
: "var(--text-secondary)",
|
|
1536
|
+
fontWeight: active ? 600 : 400,
|
|
1537
|
+
transition: "all 0.15s",
|
|
1538
|
+
}}
|
|
1539
|
+
onClick=${() =>
|
|
1540
|
+
setRegisterFields((prev) =>
|
|
1541
|
+
active
|
|
1542
|
+
? prev.filter((x) => x !== f.name)
|
|
1543
|
+
: [...prev, f.name],
|
|
1544
|
+
)}
|
|
1545
|
+
>
|
|
1546
|
+
${f.name}
|
|
1547
|
+
</button>`;
|
|
1548
|
+
})}
|
|
1549
|
+
</div>
|
|
1550
|
+
</div>`}
|
|
1551
|
+
${routeType === "register" &&
|
|
1552
|
+
modelName &&
|
|
1553
|
+
modelFields.length > 0 &&
|
|
1554
|
+
html`<div className="form-group">
|
|
1555
|
+
<label className="form-label">Check duplicate by</label>
|
|
1556
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
1557
|
+
${modelFields.map(
|
|
1558
|
+
(f) =>
|
|
1559
|
+
html`<button
|
|
1560
|
+
key=${f.name}
|
|
1561
|
+
style=${{
|
|
1562
|
+
padding: "5px 12px",
|
|
1563
|
+
borderRadius: "6px",
|
|
1564
|
+
fontSize: "12px",
|
|
1565
|
+
cursor: "pointer",
|
|
1566
|
+
border:
|
|
1567
|
+
loginField === f.name
|
|
1568
|
+
? "1.5px solid var(--accent)"
|
|
1569
|
+
: "1.5px solid var(--border)",
|
|
1570
|
+
background:
|
|
1571
|
+
loginField === f.name
|
|
1572
|
+
? "var(--accent-glow)"
|
|
1573
|
+
: "var(--bg-input)",
|
|
1574
|
+
color:
|
|
1575
|
+
loginField === f.name
|
|
1576
|
+
? "var(--accent-light)"
|
|
1577
|
+
: "var(--text-secondary)",
|
|
1578
|
+
fontWeight: loginField === f.name ? 600 : 400,
|
|
1579
|
+
transition: "all 0.15s",
|
|
1580
|
+
}}
|
|
1581
|
+
onClick=${() => setLoginField(f.name)}
|
|
1582
|
+
>
|
|
1583
|
+
${f.name}
|
|
1584
|
+
</button>`,
|
|
1585
|
+
)}
|
|
1586
|
+
</div>
|
|
1587
|
+
</div>`}
|
|
1588
|
+
${routeType === "register" &&
|
|
1589
|
+
modelName &&
|
|
1590
|
+
modelFields.length > 0 &&
|
|
1591
|
+
html`<div className="form-group">
|
|
1592
|
+
<label className="form-label"
|
|
1593
|
+
>Password field
|
|
1594
|
+
<span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
|
|
1595
|
+
>(optional — saves as hashed)</span
|
|
1596
|
+
></label
|
|
1597
|
+
>
|
|
1598
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
1599
|
+
<button
|
|
1600
|
+
style=${{
|
|
1601
|
+
padding: "5px 12px",
|
|
1602
|
+
borderRadius: "6px",
|
|
1603
|
+
fontSize: "12px",
|
|
1604
|
+
cursor: "pointer",
|
|
1605
|
+
border: !passwordField
|
|
1606
|
+
? "1.5px solid var(--accent)"
|
|
1607
|
+
: "1.5px solid var(--border)",
|
|
1608
|
+
background: !passwordField
|
|
1609
|
+
? "var(--accent-glow)"
|
|
1610
|
+
: "var(--bg-input)",
|
|
1611
|
+
color: !passwordField
|
|
1612
|
+
? "var(--accent-light)"
|
|
1613
|
+
: "var(--text-secondary)",
|
|
1614
|
+
fontWeight: !passwordField ? 600 : 400,
|
|
1615
|
+
transition: "all 0.15s",
|
|
1616
|
+
}}
|
|
1617
|
+
onClick=${() => setPasswordField("")}
|
|
1618
|
+
>
|
|
1619
|
+
None
|
|
1620
|
+
</button>
|
|
1621
|
+
${registerFields.map(
|
|
1622
|
+
(f) =>
|
|
1623
|
+
html`<button
|
|
1624
|
+
key=${f}
|
|
1625
|
+
style=${{
|
|
1626
|
+
padding: "5px 12px",
|
|
1627
|
+
borderRadius: "6px",
|
|
1628
|
+
fontSize: "12px",
|
|
1629
|
+
cursor: "pointer",
|
|
1630
|
+
border:
|
|
1631
|
+
passwordField === f
|
|
1632
|
+
? "1.5px solid var(--warning)"
|
|
1633
|
+
: "1.5px solid var(--border)",
|
|
1634
|
+
background:
|
|
1635
|
+
passwordField === f
|
|
1636
|
+
? "var(--warning-glow)"
|
|
1637
|
+
: "var(--bg-input)",
|
|
1638
|
+
color:
|
|
1639
|
+
passwordField === f
|
|
1640
|
+
? "var(--warning)"
|
|
1641
|
+
: "var(--text-secondary)",
|
|
1642
|
+
fontWeight: passwordField === f ? 600 : 400,
|
|
1643
|
+
transition: "all 0.15s",
|
|
1644
|
+
}}
|
|
1645
|
+
onClick=${() => setPasswordField(f)}
|
|
1646
|
+
>
|
|
1647
|
+
${f}
|
|
1648
|
+
</button>`,
|
|
1649
|
+
)}
|
|
1650
|
+
</div>
|
|
1651
|
+
</div>`}
|
|
1652
|
+
|
|
1653
|
+
</div>
|
|
1654
|
+
</div>`;
|
|
1655
|
+
}
|
|
1656
|
+
|