4bnode 4.1.2 → 4.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/add-security.js +0 -18
  2. package/app-1/.4bnode/.passkey +1 -0
  3. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  4. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  5. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  6. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  7. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  8. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  9. package/app-1/.4bnode/assets/js/App.js +139 -0
  10. package/app-1/.4bnode/assets/js/Root.js +40 -0
  11. package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
  12. package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
  13. package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
  14. package/app-1/.4bnode/assets/js/context.js +7 -0
  15. package/app-1/.4bnode/assets/js/lib.js +113 -0
  16. package/app-1/.4bnode/assets/js/main.js +5 -0
  17. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  18. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
  19. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
  20. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
  21. package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
  22. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
  23. package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
  24. package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
  25. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
  26. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
  27. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
  28. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  29. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
  30. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
  31. package/app-1/.4bnode/codegen.js +1249 -0
  32. package/app-1/.4bnode/dev-api.js +3621 -0
  33. package/app-1/.4bnode/ui.html +1578 -0
  34. package/app-1/README.md +47 -0
  35. package/app-1/ecosystem.config.cjs +25 -0
  36. package/app-1/index.js +145 -0
  37. package/app-1/package.json +35 -0
  38. package/app-1/public/index.html +292 -0
  39. package/app-1/src/db.js +13 -0
  40. package/app-1/src/docs.js +84 -0
  41. package/app-1/src/middleware/errorHandler.js +15 -0
  42. package/app-1/src/middleware/roles.js +14 -0
  43. package/app-1/src/middleware/validate.js +20 -0
  44. package/app-1/src/models/login.js +9 -0
  45. package/app-1/src/openapi.json +62 -0
  46. package/app-1/src/routes/products.js +26 -0
  47. package/app-1/src/validators/products.js +7 -0
  48. package/app-1/src/validators/user.js +7 -0
  49. package/index.js +2 -17
  50. package/lib/codegen.js +91 -44
  51. package/lib/codegen.test.js +23 -3
  52. package/package.json +1 -1
  53. package/skeleton/.4bnode/assets/js/App.js +0 -2
  54. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
  55. package/skeleton/.4bnode/assets/js/lib.js +0 -3
  56. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
  57. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
  58. package/skeleton/.4bnode/codegen.js +91 -44
  59. package/skeleton/.4bnode/dev-api.js +173 -87
  60. package/skeleton/_gitignore +0 -1
  61. package/skeleton/index.js +4 -8
  62. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +0 -164
@@ -0,0 +1,1436 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { LuPlug } 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 [showAddMethod, setShowAddMethod] = useState(false);
13
+ const [amRouteType, setAmRouteType] = useState(null);
14
+ const [amMethods, setAmMethods] = useState([]);
15
+ const [amModelName, setAmModelName] = useState("");
16
+ const [amLoginField, setAmLoginField] = useState("");
17
+ const [amPasswordField, setAmPasswordField] = useState("");
18
+ const [amRegisterFields, setAmRegisterFields] = useState([]);
19
+ const [amSelectedFields, setAmSelectedFields] = useState([]);
20
+ const [amCustomFields, setAmCustomFields] = useState([]);
21
+ const [amNewFieldName, setAmNewFieldName] = useState("");
22
+ const [amNewFieldType, setAmNewFieldType] = useState("String");
23
+ const [amLoading, setAmLoading] = useState(false);
24
+
25
+ // Auto-select first route
26
+ useEffect(() => {
27
+ if (data.routes?.length > 0 && !selectedRoute && !showCreate) {
28
+ selectRoute(data.routes[0]);
29
+ }
30
+ }, [data.routes]);
31
+
32
+ async function selectRoute(filename) {
33
+ setShowCreate(false);
34
+ setSelectedRoute(filename);
35
+ if (!routeContents[filename]) {
36
+ setLoadingRoute(filename);
37
+ try {
38
+ const result = await api("/routes/" + filename + "/content");
39
+ setRouteContents((prev) => ({ ...prev, [filename]: result }));
40
+ } catch (err) {
41
+ toast(err.message, "error");
42
+ }
43
+ setLoadingRoute(null);
44
+ }
45
+ }
46
+
47
+ function parseEndpoints(content) {
48
+ if (!content) return [];
49
+ const endpoints = [];
50
+ const regex =
51
+ /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]/gi;
52
+ let m;
53
+ while ((m = regex.exec(content)) !== null) {
54
+ endpoints.push({ method: m[1].toUpperCase(), path: m[2] });
55
+ }
56
+ return endpoints;
57
+ }
58
+
59
+ function getMethodsForFile(filename) {
60
+ const content = routeContents[filename]?.content;
61
+ if (!content) return [];
62
+ const methods = [];
63
+ const regex = /router\.(get|post|put|patch|delete)\(/gi;
64
+ let m;
65
+ while ((m = regex.exec(content)) !== null) {
66
+ const method = m[1].toUpperCase();
67
+ if (!methods.includes(method)) methods.push(method);
68
+ }
69
+ return methods;
70
+ }
71
+
72
+ const selName = selectedRoute?.replace(".js", "") || "";
73
+ const selContent = routeContents[selectedRoute]?.content;
74
+ const selEndpoints = parseEndpoints(selContent);
75
+ const hasAuth = selContent?.includes("import auth") || false;
76
+ const hasModel = selContent?.match(
77
+ /import\s+(\w+)\s+from\s+'\.\.\/models/,
78
+ );
79
+
80
+ // When no routes yet, show empty + create in a simpler layout
81
+ if (data.routes?.length === 0 && !showCreate) {
82
+ return html`<div>
83
+ <div className="flex items-center justify-between mb-4">
84
+ <div>
85
+ <div className="card-title">API Routes</div>
86
+ <div className="card-subtitle">0 route files</div>
87
+ </div>
88
+ <button
89
+ className="btn btn-primary"
90
+ onClick=${() => setShowCreate(true)}
91
+ >
92
+ <span className="sidebar-icon">${Icons.plus}</span> New Route
93
+ </button>
94
+ </div>
95
+ <div className="empty-state">
96
+ <div className="empty-icon"><${LuPlug} size=${36} /></div>
97
+ <h3>No routes yet</h3>
98
+ <p>Create your first API endpoint to get started</p>
99
+ <button
100
+ className="btn btn-primary"
101
+ onClick=${() => setShowCreate(true)}
102
+ >
103
+ Create Route
104
+ </button>
105
+ </div>
106
+ </div>`;
107
+ }
108
+
109
+ return html`<div>
110
+ <div className="flex items-center justify-between mb-4">
111
+ <div>
112
+ <div className="card-title">API Routes</div>
113
+ <div className="card-subtitle">
114
+ ${data.routes?.length || 0} route
115
+ file${data.routes?.length !== 1 ? "s" : ""}
116
+ </div>
117
+ </div>
118
+ <button
119
+ className=${`btn ${showCreate ? "btn-ghost" : "btn-primary"}`}
120
+ onClick=${() => {
121
+ if (showCreate) {
122
+ setShowCreate(false);
123
+ if (data.routes?.length) selectRoute(data.routes[0]);
124
+ } else {
125
+ setShowCreate(true);
126
+ setSelectedRoute(null);
127
+ }
128
+ }}
129
+ >
130
+ ${showCreate
131
+ ? "Cancel"
132
+ : html`<span className="sidebar-icon">${Icons.plus}</span> New
133
+ Route`}
134
+ </button>
135
+ </div>
136
+
137
+ <div className="routes-layout">
138
+ <div className="card" style=${{ padding: 0, overflow: "hidden" }}>
139
+ <div
140
+ style=${{
141
+ padding: "14px 16px 10px",
142
+ borderBottom: "1px solid var(--border)",
143
+ }}
144
+ >
145
+ <span
146
+ style=${{
147
+ fontWeight: 600,
148
+ fontSize: "12px",
149
+ textTransform: "uppercase",
150
+ letterSpacing: "0.5px",
151
+ color: "var(--text-muted)",
152
+ }}
153
+ >Files</span
154
+ >
155
+ </div>
156
+ <div className="routes-list">
157
+ ${(data.routes || []).map((r) => {
158
+ const rName = r.replace(".js", "");
159
+ const methods = getMethodsForFile(r);
160
+ const isActive = selectedRoute === r && !showCreate;
161
+ return html`<div
162
+ key=${r}
163
+ className=${`route-item ${isActive ? "active" : ""}`}
164
+ onClick=${() => selectRoute(r)}
165
+ >
166
+ <div
167
+ style=${{
168
+ display: "flex",
169
+ flexDirection: "column",
170
+ gap: "4px",
171
+ flex: 1,
172
+ minWidth: 0,
173
+ }}
174
+ >
175
+ <span className="ri-name">${rName}</span>
176
+ <span className="ri-file">src/routes/${r}</span>
177
+ </div>
178
+ <div className="flex gap-1" style=${{ flexWrap: "wrap" }}>
179
+ ${methods.length > 0
180
+ ? methods.map(
181
+ (m) =>
182
+ html`<span
183
+ key=${m}
184
+ className=${`route-method ${m.toLowerCase()}`}
185
+ >${m}</span
186
+ >`,
187
+ )
188
+ : html`<span className="route-method multi">...</span>`}
189
+ </div>
190
+ </div>`;
191
+ })}
192
+ <div
193
+ className=${`route-item ${showCreate ? "active" : ""}`}
194
+ onClick=${() => {
195
+ setShowCreate(true);
196
+ setSelectedRoute(null);
197
+ }}
198
+ style=${{
199
+ borderTop: data.routes?.length
200
+ ? "1px solid var(--border)"
201
+ : "none",
202
+ }}
203
+ >
204
+ <span
205
+ className="sidebar-icon"
206
+ style=${{ color: "var(--accent-light)" }}
207
+ >${Icons.plus}</span
208
+ >
209
+ <span
210
+ className="ri-name"
211
+ style=${{ color: "var(--accent-light)" }}
212
+ >New Route</span
213
+ >
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ <div className="routes-detail">
219
+ ${showCreate &&
220
+ html`<${CreateRoutePanel}
221
+ onCreated=${() => {
222
+ setShowCreate(false);
223
+ refresh();
224
+ }}
225
+ />`}
226
+ ${!showCreate &&
227
+ !selectedRoute &&
228
+ html`<div className="card">
229
+ <div className="empty-state" style=${{ padding: "40px" }}>
230
+ <p>Select a route to view details</p>
231
+ </div>
232
+ </div>`}
233
+ ${!showCreate &&
234
+ selectedRoute &&
235
+ html`<div>
236
+ <div className="card mb-4">
237
+ <div className="flex items-center justify-between mb-3">
238
+ <div>
239
+ <div
240
+ style=${{
241
+ fontWeight: 600,
242
+ fontSize: "16px",
243
+ marginBottom: "2px",
244
+ }}
245
+ >
246
+ ${selName}
247
+ </div>
248
+ <div
249
+ className="text-sm text-muted"
250
+ style=${{
251
+ fontFamily: "'SF Mono', 'Fira Code', monospace",
252
+ }}
253
+ >
254
+ src/routes/${selectedRoute}
255
+ </div>
256
+ </div>
257
+ <div className="flex gap-2 items-center">
258
+ ${hasAuth &&
259
+ html`<span className="badge badge-warning">Auth</span>`}
260
+ ${hasModel &&
261
+ html`<span className="badge badge-info"
262
+ >Model: ${hasModel[1]}</span
263
+ >`}
264
+ <button
265
+ className=${`btn btn-sm ${showAddMethod ? 'btn-primary' : 'btn-ghost'}`}
266
+ onClick=${() => {
267
+ setShowAddMethod(!showAddMethod);
268
+ setAmRouteType(null);
269
+ setAmMethods([]);
270
+ setAmModelName("");
271
+ setAmLoginField("");
272
+ setAmPasswordField("");
273
+ setAmRegisterFields([]);
274
+ setAmSelectedFields([]);
275
+ setAmCustomFields([]);
276
+ }}
277
+ >
278
+ ${showAddMethod ? "Cancel" : html`<span className="sidebar-icon">${Icons.plus}</span> Add`}
279
+ </button>
280
+ <button
281
+ className="btn btn-danger btn-sm btn-icon"
282
+ onClick=${async () => {
283
+ const ok = await showConfirm('Delete route "' + selName + '"? This will remove the route file and its registration from index.js.', { danger: true, confirmLabel: 'Delete Route' });
284
+ if (!ok) return;
285
+ try {
286
+ await api("/routes/" + selectedRoute, {
287
+ method: "DELETE",
288
+ });
289
+ toast('Route "' + selName + '" deleted', "success");
290
+ setSelectedRoute(null);
291
+ setRouteContents((prev) => {
292
+ const n = { ...prev };
293
+ delete n[selectedRoute];
294
+ return n;
295
+ });
296
+ refresh();
297
+ } catch (err) {
298
+ toast(err.message, "error");
299
+ }
300
+ }}
301
+ >
302
+ <span className="sidebar-icon">${Icons.trash}</span>
303
+ </button>
304
+ </div>
305
+ </div>
306
+
307
+ ${selEndpoints.length > 0 &&
308
+ html`<div>
309
+ <div
310
+ style=${{
311
+ fontSize: "11px",
312
+ fontWeight: 600,
313
+ textTransform: "uppercase",
314
+ letterSpacing: "0.5px",
315
+ color: "var(--text-muted)",
316
+ marginBottom: "8px",
317
+ }}
318
+ >
319
+ Endpoints
320
+ </div>
321
+ ${selEndpoints.map(
322
+ (ep, i) =>
323
+ html`<div className="route-endpoint-row" key=${i}>
324
+ <span
325
+ className=${`route-method ${ep.method.toLowerCase()}`}
326
+ >${ep.method}</span
327
+ >
328
+ <span className="route-endpoint-path"
329
+ >/api/${selName}${ep.path === "/"
330
+ ? ""
331
+ : ep.path}</span
332
+ >
333
+ </div>`,
334
+ )}
335
+ </div>`}
336
+ </div>
337
+
338
+ ${(() => {
339
+ const amNeedsModel = amRouteType && amRouteType !== "methods" && amRouteType !== "plain";
340
+ const amIsAuthType = amRouteType === "login" || amRouteType === "register";
341
+ const amModelObj = data.models?.find(m => m.name === amModelName);
342
+ const amModelFields = amModelObj?.fields || [];
343
+ const amMethodColors = { get: "#22c55e", post: "#3b82f6", put: "#f59e0b", patch: "#a855f7", delete: "#ef4444" };
344
+ const existingMethods = selEndpoints.map(ep => ep.method.toLowerCase());
345
+ const amCanSubmit = amRouteType && (amRouteType !== "methods" || amMethods.length > 0) && (amRouteType !== "crud" || amMethods.length > 0) && (!amNeedsModel || amModelName) && (!amIsAuthType || amLoginField);
346
+
347
+ return showAddMethod && html`<div className="card mb-4">
348
+ <div style=${{ fontSize: "15px", fontWeight: 600, marginBottom: "14px" }}>Add to ${selName}</div>
349
+
350
+ <div className="form-group">
351
+ <label className="form-label">Type</label>
352
+ <div style=${{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: "6px" }}>
353
+ ${[
354
+ { id: "methods", label: "Methods", desc: "GET, POST, PUT...", color: "var(--accent-light)" },
355
+ { id: "crud", label: "CRUD", desc: "Model CRUD ops", color: "#3b82f6" },
356
+ { id: "login", label: "Login", desc: "JWT login", color: "#22d3ee" },
357
+ { id: "register", label: "Register", desc: "Sign up", color: "#34d399" },
358
+ ].map(t => {
359
+ const active = amRouteType === t.id;
360
+ return html`<button key=${t.id} style=${{
361
+ padding: "8px 4px", borderRadius: "var(--radius)", cursor: "pointer", textAlign: "center",
362
+ border: active ? "1.5px solid " + t.color : "1.5px solid var(--border)",
363
+ background: active ? "rgba(255,255,255,0.04)" : "var(--bg-input)",
364
+ opacity: active ? 1 : 0.5, transition: "all 0.15s",
365
+ }} onClick=${() => { setAmRouteType(prev => prev === t.id ? null : t.id); if (t.id !== "methods" && t.id !== "crud") setAmMethods([]); }}>
366
+ <div style=${{ fontSize: "11px", fontWeight: 700, color: active ? t.color : "var(--text-secondary)" }}>${t.label}</div>
367
+ <div style=${{ fontSize: "9px", color: "var(--text-muted)", marginTop: "1px" }}>${t.desc}</div>
368
+ </button>`;
369
+ })}
370
+ </div>
371
+ </div>
372
+
373
+ ${amRouteType === "methods" && html`<div className="form-group">
374
+ <label className="form-label">HTTP Methods</label>
375
+ <div className="flex gap-2">
376
+ ${["get", "post", "put", "patch", "delete"].map(m => html`<button key=${m}
377
+ className=${`route-method ${m}`}
378
+ style=${{
379
+ cursor: existingMethods.includes(m) ? "not-allowed" : "pointer",
380
+ border: amMethods.includes(m) ? "2px solid " + amMethodColors[m] : "2px solid transparent",
381
+ padding: "6px 12px", fontSize: "11px",
382
+ opacity: existingMethods.includes(m) ? 0.15 : amMethods.includes(m) ? 1 : 0.35,
383
+ borderRadius: "6px", transition: "all 0.15s",
384
+ textDecoration: existingMethods.includes(m) ? "line-through" : "none",
385
+ }}
386
+ disabled=${existingMethods.includes(m)}
387
+ onClick=${() => !existingMethods.includes(m) && setAmMethods(prev => prev.includes(m) ? prev.filter(x => x !== m) : [...prev, m])}
388
+ >${m.toUpperCase()}</button>`)}
389
+ </div>
390
+ </div>`}
391
+
392
+ ${amRouteType === "crud" && html`<div className="form-group">
393
+ <label className="form-label">CRUD Methods <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(select operations)</span></label>
394
+ <div className="flex gap-2">
395
+ ${["get", "post", "put", "patch", "delete"].map(m => {
396
+ const labels = { get: "READ", post: "CREATE", put: "UPDATE", patch: "PATCH", delete: "DELETE" };
397
+ return html`<button key=${m} className=${`route-method ${m}`} style=${{
398
+ cursor: existingMethods.includes(m) ? "not-allowed" : "pointer",
399
+ border: amMethods.includes(m) ? "2px solid " + amMethodColors[m] : "2px solid transparent",
400
+ padding: "6px 12px", fontSize: "11px",
401
+ opacity: existingMethods.includes(m) ? 0.15 : amMethods.includes(m) ? 1 : 0.35,
402
+ borderRadius: "6px", transition: "all 0.15s",
403
+ textDecoration: existingMethods.includes(m) ? "line-through" : "none",
404
+ }} disabled=${existingMethods.includes(m)}
405
+ onClick=${() => !existingMethods.includes(m) && setAmMethods(prev => prev.includes(m) ? prev.filter(x => x !== m) : [...prev, m])}
406
+ >${labels[m]}</button>`;
407
+ })}
408
+ </div>
409
+ </div>`}
410
+
411
+ ${amNeedsModel && html`<div className="form-group">
412
+ <label className="form-label">Model</label>
413
+ <select className="form-select" value=${amModelName} onChange=${(e) => { setAmModelName(e.target.value); setAmLoginField(""); setAmPasswordField(""); setAmRegisterFields([]); }}>
414
+ <option value="">Select a model...</option>
415
+ ${(data.models || []).map(m => html`<option key=${m.name} value=${m.name}>${m.name}</option>`)}
416
+ </select>
417
+ </div>`}
418
+
419
+ ${amRouteType === "methods" && html`<div className="form-group">
420
+ <label className="form-label">Fields from Model <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(optional)</span></label>
421
+ ${data.models?.length > 0 ? html`<select className="form-select" style=${{ marginBottom: "8px" }} value=${amModelName}
422
+ onChange=${(e) => { setAmModelName(e.target.value); setAmSelectedFields([]); }}>
423
+ <option value="">Select a model...</option>
424
+ ${data.models.map(m => html`<option key=${m.name} value=${m.name}>${m.name}</option>`)}
425
+ </select>
426
+ ${amModelName && amModelFields.length > 0 && html`<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
427
+ ${amModelFields.map(f => {
428
+ const active = amSelectedFields.includes(f.name);
429
+ return html`<button key=${f.name} style=${{
430
+ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
431
+ border: active ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
432
+ background: active ? "var(--accent-glow)" : "var(--bg-input)",
433
+ color: active ? "var(--accent-light)" : "var(--text-secondary)",
434
+ fontWeight: active ? 600 : 400, transition: "all 0.15s",
435
+ }} onClick=${() => setAmSelectedFields(prev => active ? prev.filter(x => x !== f.name) : [...prev, f.name])}>
436
+ ${f.name} <span style=${{ fontSize: "10px", color: "var(--text-muted)", marginLeft: "4px" }}>${f.type}</span>
437
+ </button>`;
438
+ })}
439
+ </div>`}` : html`<div style=${{ fontSize: "11px", color: "var(--text-muted)" }}>No models found.</div>`}
440
+ </div>`}
441
+
442
+ ${amRouteType === "login" && amModelName && amModelFields.length > 0 && html`<div className="form-group">
443
+ <label className="form-label">Find user by</label>
444
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
445
+ ${amModelFields.map(f => html`<button key=${f.name} style=${{
446
+ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
447
+ border: amLoginField === f.name ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
448
+ background: amLoginField === f.name ? "var(--accent-glow)" : "var(--bg-input)",
449
+ color: amLoginField === f.name ? "var(--accent-light)" : "var(--text-secondary)",
450
+ fontWeight: amLoginField === f.name ? 600 : 400, transition: "all 0.15s",
451
+ }} onClick=${() => setAmLoginField(f.name)}>${f.name}</button>`)}
452
+ </div>
453
+ </div>`}
454
+
455
+ ${amIsAuthType && amModelName && amModelFields.length > 0 && html`<div className="form-group">
456
+ <label className="form-label">Password field <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(optional — enables bcrypt)</span></label>
457
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
458
+ <button style=${{
459
+ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
460
+ border: !amPasswordField ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
461
+ background: !amPasswordField ? "var(--accent-glow)" : "var(--bg-input)",
462
+ color: !amPasswordField ? "var(--accent-light)" : "var(--text-secondary)",
463
+ fontWeight: !amPasswordField ? 600 : 400, transition: "all 0.15s",
464
+ }} onClick=${() => setAmPasswordField("")}>None</button>
465
+ ${amModelFields.map(f => html`<button key=${f.name} style=${{
466
+ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
467
+ border: amPasswordField === f.name ? "1.5px solid var(--warning)" : "1.5px solid var(--border)",
468
+ background: amPasswordField === f.name ? "var(--warning-glow)" : "var(--bg-input)",
469
+ color: amPasswordField === f.name ? "var(--warning)" : "var(--text-secondary)",
470
+ fontWeight: amPasswordField === f.name ? 600 : 400, transition: "all 0.15s",
471
+ }} onClick=${() => setAmPasswordField(f.name)}>${f.name}</button>`)}
472
+ </div>
473
+ </div>`}
474
+
475
+ ${amRouteType === "register" && amModelName && amModelFields.length > 0 && html`<div className="form-group">
476
+ <label className="form-label">Registration fields</label>
477
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
478
+ ${amModelFields.map(f => {
479
+ const active = amRegisterFields.includes(f.name);
480
+ return html`<button key=${f.name} style=${{
481
+ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
482
+ border: active ? "1.5px solid var(--success)" : "1.5px solid var(--border)",
483
+ background: active ? "var(--success-glow)" : "var(--bg-input)",
484
+ color: active ? "var(--success)" : "var(--text-secondary)",
485
+ fontWeight: active ? 600 : 400, transition: "all 0.15s",
486
+ }} onClick=${() => setAmRegisterFields(prev => active ? prev.filter(x => x !== f.name) : [...prev, f.name])}>${f.name}</button>`;
487
+ })}
488
+ </div>
489
+ </div>`}
490
+
491
+ ${amRouteType === "register" && amModelName && amModelFields.length > 0 && html`<div className="form-group">
492
+ <label className="form-label">Check duplicate by</label>
493
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
494
+ ${amModelFields.map(f => html`<button key=${f.name} style=${{
495
+ padding: "5px 12px", borderRadius: "6px", fontSize: "12px", cursor: "pointer",
496
+ border: amLoginField === f.name ? "1.5px solid var(--accent)" : "1.5px solid var(--border)",
497
+ background: amLoginField === f.name ? "var(--accent-glow)" : "var(--bg-input)",
498
+ color: amLoginField === f.name ? "var(--accent-light)" : "var(--text-secondary)",
499
+ fontWeight: amLoginField === f.name ? 600 : 400, transition: "all 0.15s",
500
+ }} onClick=${() => setAmLoginField(f.name)}>${f.name}</button>`)}
501
+ </div>
502
+ </div>`}
503
+
504
+ ${amRouteType === "methods" && html`<div className="form-group">
505
+ <label className="form-label">Custom Fields <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}>(from client)</span></label>
506
+ ${amCustomFields.length > 0 && html`<div style=${{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "8px" }}>
507
+ ${amCustomFields.map((f, i) => html`<span key=${i} style=${{
508
+ padding: "4px 10px", borderRadius: "6px", fontSize: "12px",
509
+ background: "var(--bg-input)", border: "1.5px solid var(--border)",
510
+ color: "var(--text-primary)", display: "flex", alignItems: "center", gap: "6px",
511
+ }}>
512
+ ${f.name} <span style=${{ fontSize: "10px", color: f.type === "File" ? "var(--warning)" : "var(--text-muted)" }}>${f.type}</span>
513
+ <span style=${{ cursor: "pointer", color: "var(--text-muted)", fontSize: "14px", lineHeight: 1 }}
514
+ onClick=${() => setAmCustomFields(prev => prev.filter((_, idx) => idx !== i))}>×</span>
515
+ </span>`)}
516
+ </div>`}
517
+ <div className="flex gap-2" style=${{ alignItems: "center" }}>
518
+ <input className="form-input" value=${amNewFieldName}
519
+ onInput=${(e) => setAmNewFieldName(e.target.value)} placeholder="Field name"
520
+ style=${{ fontSize: "13px", padding: "8px 12px", flex: 1 }}
521
+ onKeyDown=${(e) => { if (e.key === "Enter" && amNewFieldName.trim()) { setAmCustomFields(prev => [...prev, { name: amNewFieldName.trim(), type: amNewFieldType }]); setAmNewFieldName(""); }}} />
522
+ <select className="form-select" value=${amNewFieldType} onChange=${(e) => setAmNewFieldType(e.target.value)} style=${{ width: "auto", minWidth: "100px" }}>
523
+ <option value="String">String</option><option value="Number">Number</option><option value="Boolean">Boolean</option>
524
+ <option value="Date">Date</option><option value="Array">Array</option><option value="Object">Object</option><option value="File">File</option>
525
+ </select>
526
+ <button className="btn btn-sm" style=${{
527
+ padding: "8px 14px", fontSize: "12px", background: "var(--bg-input)",
528
+ border: "1.5px solid var(--border)", color: "var(--text-primary)",
529
+ cursor: amNewFieldName.trim() ? "pointer" : "not-allowed",
530
+ opacity: amNewFieldName.trim() ? 1 : 0.4, borderRadius: "6px",
531
+ }} disabled=${!amNewFieldName.trim()} onClick=${() => { if (amNewFieldName.trim()) { setAmCustomFields(prev => [...prev, { name: amNewFieldName.trim(), type: amNewFieldType }]); setAmNewFieldName(""); } }}>+ Add</button>
532
+ </div>
533
+ </div>`}
534
+
535
+ ${amCanSubmit && html`<button className="btn btn-primary" disabled=${amLoading}
536
+ onClick=${async () => {
537
+ setAmLoading(true);
538
+ try {
539
+ const result = await api("/routes/" + selectedRoute + "/add-method", {
540
+ method: "POST",
541
+ body: {
542
+ routeType: amRouteType,
543
+ methods: (amRouteType === "methods" || amRouteType === "crud") ? amMethods : undefined,
544
+ modelName: amModelName || undefined,
545
+ loginField: amIsAuthType ? amLoginField : undefined,
546
+ passwordField: amIsAuthType && amPasswordField ? amPasswordField : undefined,
547
+ registerFields: amRouteType === "register" ? amRegisterFields : undefined,
548
+ selectedFields: amRouteType === "methods" && amSelectedFields.length > 0 ? amSelectedFields : undefined,
549
+ customFields: amRouteType === "methods" && amCustomFields.length > 0 ? amCustomFields : undefined,
550
+ },
551
+ });
552
+ toast(result.message, "success");
553
+ setRouteContents(prev => ({ ...prev, [selectedRoute]: { content: result.content } }));
554
+ setShowAddMethod(false);
555
+ refresh();
556
+ } catch (err) { toast(err.message, "error"); }
557
+ setAmLoading(false);
558
+ }}
559
+ >${amLoading ? html`<span className="spinner" />` : "Add to Route"}</button>`}
560
+ </div>`;
561
+ })()}
562
+
563
+ <div
564
+ className="card"
565
+ style=${{ padding: 0, overflow: "hidden" }}
566
+ >
567
+ <div
568
+ style=${{
569
+ padding: "12px 16px",
570
+ borderBottom: "1px solid var(--border)",
571
+ display: "flex",
572
+ alignItems: "center",
573
+ justifyContent: "space-between",
574
+ }}
575
+ >
576
+ <span
577
+ style=${{
578
+ fontSize: "12px",
579
+ fontWeight: 600,
580
+ color: "var(--text-secondary)",
581
+ }}
582
+ >Source Code</span
583
+ >
584
+ <span className="field-count"
585
+ >${selContent
586
+ ? selContent.split("\\n").length + " lines"
587
+ : ""}</span
588
+ >
589
+ </div>
590
+ ${loadingRoute === selectedRoute &&
591
+ html`<div style=${{ padding: "30px", textAlign: "center" }}>
592
+ <span className="spinner" />
593
+ </div>`}
594
+ ${selContent &&
595
+ html`<div className="route-code-wrap">
596
+ <div className="code-block">${selContent}</div>
597
+ </div>`}
598
+ </div>
599
+ </div>`}
600
+ </div>
601
+ </div>
602
+ </div>`;
603
+ }
604
+
605
+ function CreateRoutePanel({ onCreated }) {
606
+ const { data, toast } = useApp();
607
+ const [name, setName] = useState("");
608
+ const [routeType, setRouteType] = useState(null);
609
+ const [methods, setMethods] = useState([]);
610
+ const pathPrefix = "/api/";
611
+ const [modelName, setModelName] = useState("");
612
+ const [loginField, setLoginField] = useState("");
613
+ const [passwordField, setPasswordField] = useState("");
614
+ const [registerFields, setRegisterFields] = useState([]);
615
+ const [selectedFields, setSelectedFields] = useState([]);
616
+ const [customFields, setCustomFields] = useState([]);
617
+ const [authRequired, setAuthRequired] = useState(false);
618
+ const [newFieldName, setNewFieldName] = useState("");
619
+ const [newFieldType, setNewFieldType] = useState("String");
620
+ const [loading, setLoading] = useState(false);
621
+ const nameRef = useRef(null);
622
+
623
+ useEffect(() => {
624
+ nameRef.current?.focus();
625
+ }, []);
626
+
627
+ const typeOptions = [
628
+ {
629
+ id: "plain",
630
+ label: "Plain",
631
+ desc: "Empty route",
632
+ color: "#94a3b8",
633
+ },
634
+ {
635
+ id: "methods",
636
+ label: "Methods",
637
+ desc: "GET, POST, PUT...",
638
+ color: "var(--accent-light)",
639
+ },
640
+ {
641
+ id: "crud",
642
+ label: "CRUD",
643
+ desc: "Model CRUD ops",
644
+ color: "#3b82f6",
645
+ },
646
+ { id: "login", label: "Login", desc: "JWT login", color: "#22d3ee" },
647
+ {
648
+ id: "register",
649
+ label: "Register",
650
+ desc: "Sign up",
651
+ color: "#34d399",
652
+ },
653
+ ];
654
+
655
+ function selectType(id) {
656
+ setRouteType((prev) => (prev === id ? null : id));
657
+ if (id !== "methods" && id !== "crud") setMethods([]);
658
+ }
659
+
660
+ function toggleMethod(m) {
661
+ setMethods((prev) =>
662
+ prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m],
663
+ );
664
+ }
665
+
666
+ const needsModel =
667
+ routeType && routeType !== "methods" && routeType !== "plain";
668
+ const isAuthType = routeType === "login" || routeType === "register";
669
+
670
+ // Get fields from selected model
671
+ const selectedModelObj = data.models?.find((m) => m.name === modelName);
672
+ const modelFields = selectedModelObj?.fields || [];
673
+
674
+ // Auto-select fields when model changes
675
+ useEffect(() => {
676
+ if (modelFields.length > 0 && isAuthType) {
677
+ if (!loginField) {
678
+ const emailField =
679
+ modelFields.find((f) => f.name === "email") || modelFields[0];
680
+ setLoginField(emailField?.name || "");
681
+ }
682
+ if (!passwordField) {
683
+ const pwField = modelFields.find((f) => f.name === "password");
684
+ setPasswordField(pwField?.name || "");
685
+ }
686
+ if (routeType === "register" && registerFields.length === 0) {
687
+ setRegisterFields(modelFields.map((f) => f.name));
688
+ }
689
+ }
690
+ }, [modelName, routeType]);
691
+
692
+ async function handleCreate() {
693
+ if (!name.trim() || !routeType) return;
694
+ if (routeType === "methods" && methods.length === 0) {
695
+ toast("Select at least one method", "error");
696
+ return;
697
+ }
698
+ if (routeType === "crud" && methods.length === 0) {
699
+ toast("Select at least one CRUD method", "error");
700
+ return;
701
+ }
702
+ if (needsModel && !modelName) {
703
+ toast("Select a model", "error");
704
+ return;
705
+ }
706
+ if (isAuthType && !loginField) {
707
+ toast("Select a login field", "error");
708
+ return;
709
+ }
710
+ setLoading(true);
711
+ try {
712
+ const result = await api("/add-api", {
713
+ method: "POST",
714
+ body: {
715
+ name: name.trim(),
716
+ routeType,
717
+ pathPrefix,
718
+ methods:
719
+ routeType === "methods" || routeType === "crud"
720
+ ? methods
721
+ : undefined,
722
+ modelName: modelName || undefined,
723
+ loginField: isAuthType ? loginField : undefined,
724
+ passwordField:
725
+ isAuthType && passwordField ? passwordField : undefined,
726
+ registerFields:
727
+ routeType === "register" ? registerFields : undefined,
728
+ selectedFields:
729
+ (routeType === "methods" || routeType === "crud") && selectedFields.length > 0
730
+ ? selectedFields
731
+ : undefined,
732
+ customFields:
733
+ (routeType === "methods" || routeType === "crud") && customFields.length > 0
734
+ ? customFields
735
+ : undefined,
736
+ authRequired: !isAuthType ? authRequired : undefined,
737
+ },
738
+ });
739
+ toast(result.message, "success");
740
+ onCreated();
741
+ } catch (err) {
742
+ toast(err.message, "error");
743
+ }
744
+ setLoading(false);
745
+ }
746
+
747
+ const methodColors = {
748
+ get: "#22c55e",
749
+ post: "#3b82f6",
750
+ put: "#f59e0b",
751
+ patch: "#a855f7",
752
+ delete: "#ef4444",
753
+ };
754
+ const canCreate =
755
+ name.trim() &&
756
+ routeType &&
757
+ (routeType !== "methods" || methods.length > 0) &&
758
+ (routeType !== "crud" || methods.length > 0) &&
759
+ (!needsModel || modelName) &&
760
+ (!isAuthType || loginField);
761
+
762
+ return html`<div>
763
+ <div className="card mb-4">
764
+ <div
765
+ className="flex items-center justify-between"
766
+ style=${{ marginBottom: "16px" }}
767
+ >
768
+ <div style=${{ fontSize: "15px", fontWeight: 600 }}>
769
+ Create New Route
770
+ </div>
771
+ <button
772
+ className="btn btn-primary btn-sm"
773
+ onClick=${handleCreate}
774
+ disabled=${loading || !canCreate}
775
+ >
776
+ ${loading
777
+ ? html`<span className="spinner" />`
778
+ : html`<span className="sidebar-icon">${Icons.plus}</span>`}
779
+ ${loading ? "Creating..." : "Create Route"}
780
+ </button>
781
+ </div>
782
+
783
+ <div className="form-group">
784
+ <label className="form-label">Route Name</label>
785
+ <input
786
+ ref=${nameRef}
787
+ className="form-input"
788
+ value=${name}
789
+ onInput=${(e) => setName(e.target.value)}
790
+ onKeyDown=${(e) =>
791
+ e.key === "Enter" && canCreate && handleCreate()}
792
+ placeholder="e.g. users, login, products"
793
+ style=${{ fontSize: "14px", padding: "11px 14px" }}
794
+ />
795
+ </div>
796
+
797
+ <div className="form-group">
798
+ <label className="form-label">Type</label>
799
+ <div
800
+ style=${{
801
+ display: "grid",
802
+ gridTemplateColumns: "repeat(5, 1fr)",
803
+ gap: "6px",
804
+ }}
805
+ >
806
+ ${typeOptions.map((t) => {
807
+ const active = routeType === t.id;
808
+ return html`<button
809
+ key=${t.id}
810
+ style=${{
811
+ padding: "8px 4px",
812
+ borderRadius: "var(--radius)",
813
+ cursor: "pointer",
814
+ textAlign: "center",
815
+ border: active
816
+ ? "1.5px solid " + t.color
817
+ : "1.5px solid var(--border)",
818
+ background: active
819
+ ? "rgba(255,255,255,0.04)"
820
+ : "var(--bg-input)",
821
+ opacity: active ? 1 : 0.5,
822
+ transition: "all 0.15s",
823
+ }}
824
+ onClick=${() => selectType(t.id)}
825
+ >
826
+ <div
827
+ style=${{
828
+ fontSize: "11px",
829
+ fontWeight: 700,
830
+ color: active ? t.color : "var(--text-secondary)",
831
+ }}
832
+ >
833
+ ${t.label}
834
+ </div>
835
+ <div
836
+ style=${{
837
+ fontSize: "9px",
838
+ color: "var(--text-muted)",
839
+ marginTop: "1px",
840
+ }}
841
+ >
842
+ ${t.desc}
843
+ </div>
844
+ </button>`;
845
+ })}
846
+ </div>
847
+ </div>
848
+
849
+ ${routeType === "methods" &&
850
+ html`<div className="form-group">
851
+ <label className="form-label">HTTP Methods</label>
852
+ <div className="flex gap-2">
853
+ ${["get", "post", "put", "patch", "delete"].map(
854
+ (m) =>
855
+ html`<button
856
+ key=${m}
857
+ className=${`route-method ${m}`}
858
+ style=${{
859
+ cursor: "pointer",
860
+ border: methods.includes(m)
861
+ ? "2px solid " + methodColors[m]
862
+ : "2px solid transparent",
863
+ padding: "6px 12px",
864
+ fontSize: "11px",
865
+ opacity: methods.includes(m) ? 1 : 0.35,
866
+ borderRadius: "6px",
867
+ transition: "all 0.15s",
868
+ }}
869
+ onClick=${() => toggleMethod(m)}
870
+ >
871
+ ${m.toUpperCase()}
872
+ </button>`,
873
+ )}
874
+ </div>
875
+ </div>`}
876
+ ${routeType === "crud" &&
877
+ html`<div className="form-group">
878
+ <label className="form-label"
879
+ >CRUD Methods
880
+ <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
881
+ >(select which operations to include)</span
882
+ ></label
883
+ >
884
+ <div className="flex gap-2">
885
+ ${["get", "post", "put", "patch", "delete"].map((m) => {
886
+ const labels = {
887
+ get: "READ",
888
+ post: "CREATE",
889
+ put: "UPDATE",
890
+ patch: "PATCH",
891
+ delete: "DELETE",
892
+ };
893
+ return html`<button
894
+ key=${m}
895
+ className=${`route-method ${m}`}
896
+ style=${{
897
+ cursor: "pointer",
898
+ border: methods.includes(m)
899
+ ? "2px solid " + methodColors[m]
900
+ : "2px solid transparent",
901
+ padding: "6px 12px",
902
+ fontSize: "11px",
903
+ opacity: methods.includes(m) ? 1 : 0.35,
904
+ borderRadius: "6px",
905
+ transition: "all 0.15s",
906
+ }}
907
+ onClick=${() => toggleMethod(m)}
908
+ >
909
+ ${labels[m]}
910
+ </button>`;
911
+ })}
912
+ </div>
913
+ </div>`}
914
+ ${isAuthType &&
915
+ html`<div className="form-group">
916
+ <label className="form-label">Model</label>
917
+ <select
918
+ className="form-select"
919
+ value=${modelName}
920
+ onChange=${(e) => {
921
+ setModelName(e.target.value);
922
+ setLoginField("");
923
+ setPasswordField("");
924
+ setRegisterFields([]);
925
+ setSelectedFields([]);
926
+ }}
927
+ >
928
+ <option value="">Select a model...</option>
929
+ ${(data.models || []).map(
930
+ (m) =>
931
+ html`<option key=${m.name} value=${m.name}>
932
+ ${m.name}
933
+ </option>`,
934
+ )}
935
+ </select>
936
+ ${!data.models?.length &&
937
+ html`<div
938
+ style=${{
939
+ fontSize: "11px",
940
+ color: "var(--warning)",
941
+ marginTop: "4px",
942
+ }}
943
+ >
944
+ No models found. Create a schema first.
945
+ </div>`}
946
+ </div>`}
947
+ ${(routeType === "methods" || routeType === "crud") &&
948
+ html`<div className="form-group">
949
+ <label className="form-label"
950
+ >Model Fields
951
+ <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
952
+ >(select fields to include in request)</span
953
+ ></label
954
+ >
955
+ <select
956
+ className="form-select"
957
+ style=${{ marginBottom: "8px" }}
958
+ value=${modelName}
959
+ onChange=${(e) => {
960
+ setModelName(e.target.value);
961
+ setSelectedFields([]);
962
+ }}
963
+ >
964
+ <option value="">Select a model...</option>
965
+ ${(data.models || []).map(
966
+ (m) =>
967
+ html`<option key=${m.name} value=${m.name}>
968
+ ${m.name}
969
+ </option>`,
970
+ )}
971
+ </select>
972
+ ${modelName &&
973
+ modelFields.length > 0 &&
974
+ html`<div>
975
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
976
+ ${modelFields.map((f) => {
977
+ const active = selectedFields.includes(f.name);
978
+ return html`<button
979
+ key=${f.name}
980
+ style=${{
981
+ padding: "5px 12px",
982
+ borderRadius: "6px",
983
+ fontSize: "12px",
984
+ cursor: "pointer",
985
+ border: active
986
+ ? "1.5px solid var(--accent)"
987
+ : "1.5px solid var(--border)",
988
+ background: active
989
+ ? "var(--accent-glow)"
990
+ : "var(--bg-input)",
991
+ color: active
992
+ ? "var(--accent-light)"
993
+ : "var(--text-secondary)",
994
+ fontWeight: active ? 600 : 400,
995
+ transition: "all 0.15s",
996
+ }}
997
+ onClick=${() =>
998
+ setSelectedFields((prev) =>
999
+ active
1000
+ ? prev.filter((x) => x !== f.name)
1001
+ : [...prev, f.name],
1002
+ )}
1003
+ >
1004
+ ${f.name}
1005
+ <span
1006
+ style=${{
1007
+ fontSize: "10px",
1008
+ color: "var(--text-muted)",
1009
+ marginLeft: "4px",
1010
+ }}
1011
+ >${f.type}</span
1012
+ >
1013
+ </button>`;
1014
+ })}
1015
+ </div>
1016
+ <div style=${{ display: "flex", gap: "8px", marginTop: "8px" }}>
1017
+ <button
1018
+ style=${{
1019
+ padding: "3px 10px",
1020
+ fontSize: "10px",
1021
+ borderRadius: "4px",
1022
+ border: "1px solid var(--border)",
1023
+ background: "transparent",
1024
+ color: "var(--accent)",
1025
+ cursor: "pointer",
1026
+ }}
1027
+ onClick=${() => setSelectedFields(modelFields.map((f) => f.name))}
1028
+ >
1029
+ Select All
1030
+ </button>
1031
+ <button
1032
+ style=${{
1033
+ padding: "3px 10px",
1034
+ fontSize: "10px",
1035
+ borderRadius: "4px",
1036
+ border: "1px solid var(--border)",
1037
+ background: "transparent",
1038
+ color: "var(--text-muted)",
1039
+ cursor: "pointer",
1040
+ }}
1041
+ onClick=${() => setSelectedFields([])}
1042
+ >
1043
+ Clear
1044
+ </button>
1045
+ </div>
1046
+ </div>`}
1047
+ ${!data.models?.length &&
1048
+ html`<div
1049
+ style=${{
1050
+ fontSize: "11px",
1051
+ color: "var(--text-muted)",
1052
+ marginTop: "4px",
1053
+ }}
1054
+ >
1055
+ No models found. You can still add custom fields below.
1056
+ </div>`}
1057
+ </div>`}
1058
+ ${(routeType === "methods" || routeType === "crud") &&
1059
+ html`<div className="form-group">
1060
+ <label className="form-label"
1061
+ >Custom Fields
1062
+ <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
1063
+ >(add extra fields received from client)</span
1064
+ ></label
1065
+ >
1066
+ ${customFields.length > 0 &&
1067
+ html`<div
1068
+ style=${{
1069
+ display: "flex",
1070
+ flexWrap: "wrap",
1071
+ gap: "6px",
1072
+ marginBottom: "8px",
1073
+ }}
1074
+ >
1075
+ ${customFields.map(
1076
+ (f, i) =>
1077
+ html`<span
1078
+ key=${i}
1079
+ style=${{
1080
+ padding: "4px 10px",
1081
+ borderRadius: "6px",
1082
+ fontSize: "12px",
1083
+ background: "var(--bg-input)",
1084
+ border: "1.5px solid var(--border)",
1085
+ color: "var(--text-primary)",
1086
+ display: "flex",
1087
+ alignItems: "center",
1088
+ gap: "6px",
1089
+ }}
1090
+ >
1091
+ ${f.name}
1092
+ <span
1093
+ style=${{
1094
+ fontSize: "10px",
1095
+ color:
1096
+ f.type === "File"
1097
+ ? "var(--warning)"
1098
+ : "var(--text-muted)",
1099
+ }}
1100
+ >${f.type}</span
1101
+ >
1102
+ <span
1103
+ style=${{
1104
+ cursor: "pointer",
1105
+ color: "var(--text-muted)",
1106
+ fontSize: "14px",
1107
+ lineHeight: 1,
1108
+ marginLeft: "2px",
1109
+ }}
1110
+ onClick=${() =>
1111
+ setCustomFields((prev) =>
1112
+ prev.filter((_, idx) => idx !== i),
1113
+ )}
1114
+ >×</span
1115
+ >
1116
+ </span>`,
1117
+ )}
1118
+ </div>`}
1119
+ <div className="flex gap-2" style=${{ alignItems: "center" }}>
1120
+ <input
1121
+ className="form-input"
1122
+ value=${newFieldName}
1123
+ onInput=${(e) => setNewFieldName(e.target.value)}
1124
+ placeholder="Field name"
1125
+ style=${{
1126
+ fontSize: "13px",
1127
+ padding: "8px 12px",
1128
+ flex: 1,
1129
+ }}
1130
+ onKeyDown=${(e) => {
1131
+ if (e.key === "Enter" && newFieldName.trim()) {
1132
+ setCustomFields((prev) => [
1133
+ ...prev,
1134
+ { name: newFieldName.trim(), type: newFieldType },
1135
+ ]);
1136
+ setNewFieldName("");
1137
+ }
1138
+ }}
1139
+ />
1140
+ <select
1141
+ className="form-select"
1142
+ value=${newFieldType}
1143
+ onChange=${(e) => setNewFieldType(e.target.value)}
1144
+ style=${{ width: "auto", minWidth: "100px" }}
1145
+ >
1146
+ <option value="String">String</option>
1147
+ <option value="Number">Number</option>
1148
+ <option value="Boolean">Boolean</option>
1149
+ <option value="Date">Date</option>
1150
+ <option value="Array">Array</option>
1151
+ <option value="Object">Object</option>
1152
+ <option value="File">File</option>
1153
+ </select>
1154
+ <button
1155
+ className="btn btn-sm"
1156
+ style=${{
1157
+ padding: "8px 14px",
1158
+ fontSize: "12px",
1159
+ background: "var(--bg-input)",
1160
+ border: "1.5px solid var(--border)",
1161
+ color: "var(--text-primary)",
1162
+ cursor: newFieldName.trim() ? "pointer" : "not-allowed",
1163
+ opacity: newFieldName.trim() ? 1 : 0.4,
1164
+ borderRadius: "6px",
1165
+ }}
1166
+ disabled=${!newFieldName.trim()}
1167
+ onClick=${() => {
1168
+ if (newFieldName.trim()) {
1169
+ setCustomFields((prev) => [
1170
+ ...prev,
1171
+ { name: newFieldName.trim(), type: newFieldType },
1172
+ ]);
1173
+ setNewFieldName("");
1174
+ }
1175
+ }}
1176
+ >
1177
+ + Add
1178
+ </button>
1179
+ </div>
1180
+ </div>`}
1181
+ ${routeType && !isAuthType &&
1182
+ html`<div className="form-group" style=${{ marginTop: "4px" }}>
1183
+ <label style=${{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
1184
+ <input type="checkbox" checked=${authRequired} onChange=${(e) => setAuthRequired(e.target.checked)} />
1185
+ <span>Require authentication (JWT)</span>
1186
+ </label>
1187
+ <div className="text-sm text-muted" style=${{ marginTop: "4px", marginLeft: "24px" }}>
1188
+ Protects every endpoint with the <span style=${{ fontFamily: "monospace" }}>auth</span> middleware — callers must send a valid Bearer token in the Authorization header.
1189
+ </div>
1190
+ </div>`}
1191
+ ${routeType === "login" &&
1192
+ modelName &&
1193
+ modelFields.length > 0 &&
1194
+ html`<div className="form-group">
1195
+ <label className="form-label">Find user by</label>
1196
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
1197
+ ${modelFields.map(
1198
+ (f) =>
1199
+ html`<button
1200
+ key=${f.name}
1201
+ style=${{
1202
+ padding: "5px 12px",
1203
+ borderRadius: "6px",
1204
+ fontSize: "12px",
1205
+ cursor: "pointer",
1206
+ border:
1207
+ loginField === f.name
1208
+ ? "1.5px solid var(--accent)"
1209
+ : "1.5px solid var(--border)",
1210
+ background:
1211
+ loginField === f.name
1212
+ ? "var(--accent-glow)"
1213
+ : "var(--bg-input)",
1214
+ color:
1215
+ loginField === f.name
1216
+ ? "var(--accent-light)"
1217
+ : "var(--text-secondary)",
1218
+ fontWeight: loginField === f.name ? 600 : 400,
1219
+ transition: "all 0.15s",
1220
+ }}
1221
+ onClick=${() => setLoginField(f.name)}
1222
+ >
1223
+ ${f.name}
1224
+ </button>`,
1225
+ )}
1226
+ </div>
1227
+ </div>`}
1228
+ ${routeType === "login" &&
1229
+ modelName &&
1230
+ modelFields.length > 0 &&
1231
+ html`<div className="form-group">
1232
+ <label className="form-label"
1233
+ >Password field
1234
+ <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
1235
+ >(optional — enables bcrypt)</span
1236
+ ></label
1237
+ >
1238
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
1239
+ <button
1240
+ style=${{
1241
+ padding: "5px 12px",
1242
+ borderRadius: "6px",
1243
+ fontSize: "12px",
1244
+ cursor: "pointer",
1245
+ border: !passwordField
1246
+ ? "1.5px solid var(--accent)"
1247
+ : "1.5px solid var(--border)",
1248
+ background: !passwordField
1249
+ ? "var(--accent-glow)"
1250
+ : "var(--bg-input)",
1251
+ color: !passwordField
1252
+ ? "var(--accent-light)"
1253
+ : "var(--text-secondary)",
1254
+ fontWeight: !passwordField ? 600 : 400,
1255
+ transition: "all 0.15s",
1256
+ }}
1257
+ onClick=${() => setPasswordField("")}
1258
+ >
1259
+ None
1260
+ </button>
1261
+ ${modelFields.map(
1262
+ (f) =>
1263
+ html`<button
1264
+ key=${f.name}
1265
+ style=${{
1266
+ padding: "5px 12px",
1267
+ borderRadius: "6px",
1268
+ fontSize: "12px",
1269
+ cursor: "pointer",
1270
+ border:
1271
+ passwordField === f.name
1272
+ ? "1.5px solid var(--warning)"
1273
+ : "1.5px solid var(--border)",
1274
+ background:
1275
+ passwordField === f.name
1276
+ ? "var(--warning-glow)"
1277
+ : "var(--bg-input)",
1278
+ color:
1279
+ passwordField === f.name
1280
+ ? "var(--warning)"
1281
+ : "var(--text-secondary)",
1282
+ fontWeight: passwordField === f.name ? 600 : 400,
1283
+ transition: "all 0.15s",
1284
+ }}
1285
+ onClick=${() => setPasswordField(f.name)}
1286
+ >
1287
+ ${f.name}
1288
+ </button>`,
1289
+ )}
1290
+ </div>
1291
+ </div>`}
1292
+ ${routeType === "register" &&
1293
+ modelName &&
1294
+ modelFields.length > 0 &&
1295
+ html`<div className="form-group">
1296
+ <label className="form-label">Registration fields</label>
1297
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
1298
+ ${modelFields.map((f) => {
1299
+ const active = registerFields.includes(f.name);
1300
+ return html`<button
1301
+ key=${f.name}
1302
+ style=${{
1303
+ padding: "5px 12px",
1304
+ borderRadius: "6px",
1305
+ fontSize: "12px",
1306
+ cursor: "pointer",
1307
+ border: active
1308
+ ? "1.5px solid var(--success)"
1309
+ : "1.5px solid var(--border)",
1310
+ background: active
1311
+ ? "var(--success-glow)"
1312
+ : "var(--bg-input)",
1313
+ color: active
1314
+ ? "var(--success)"
1315
+ : "var(--text-secondary)",
1316
+ fontWeight: active ? 600 : 400,
1317
+ transition: "all 0.15s",
1318
+ }}
1319
+ onClick=${() =>
1320
+ setRegisterFields((prev) =>
1321
+ active
1322
+ ? prev.filter((x) => x !== f.name)
1323
+ : [...prev, f.name],
1324
+ )}
1325
+ >
1326
+ ${f.name}
1327
+ </button>`;
1328
+ })}
1329
+ </div>
1330
+ </div>`}
1331
+ ${routeType === "register" &&
1332
+ modelName &&
1333
+ modelFields.length > 0 &&
1334
+ html`<div className="form-group">
1335
+ <label className="form-label">Check duplicate by</label>
1336
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
1337
+ ${modelFields.map(
1338
+ (f) =>
1339
+ html`<button
1340
+ key=${f.name}
1341
+ style=${{
1342
+ padding: "5px 12px",
1343
+ borderRadius: "6px",
1344
+ fontSize: "12px",
1345
+ cursor: "pointer",
1346
+ border:
1347
+ loginField === f.name
1348
+ ? "1.5px solid var(--accent)"
1349
+ : "1.5px solid var(--border)",
1350
+ background:
1351
+ loginField === f.name
1352
+ ? "var(--accent-glow)"
1353
+ : "var(--bg-input)",
1354
+ color:
1355
+ loginField === f.name
1356
+ ? "var(--accent-light)"
1357
+ : "var(--text-secondary)",
1358
+ fontWeight: loginField === f.name ? 600 : 400,
1359
+ transition: "all 0.15s",
1360
+ }}
1361
+ onClick=${() => setLoginField(f.name)}
1362
+ >
1363
+ ${f.name}
1364
+ </button>`,
1365
+ )}
1366
+ </div>
1367
+ </div>`}
1368
+ ${routeType === "register" &&
1369
+ modelName &&
1370
+ modelFields.length > 0 &&
1371
+ html`<div className="form-group">
1372
+ <label className="form-label"
1373
+ >Password field
1374
+ <span style=${{ color: "var(--text-muted)", fontWeight: 400 }}
1375
+ >(optional — saves as hashed)</span
1376
+ ></label
1377
+ >
1378
+ <div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
1379
+ <button
1380
+ style=${{
1381
+ padding: "5px 12px",
1382
+ borderRadius: "6px",
1383
+ fontSize: "12px",
1384
+ cursor: "pointer",
1385
+ border: !passwordField
1386
+ ? "1.5px solid var(--accent)"
1387
+ : "1.5px solid var(--border)",
1388
+ background: !passwordField
1389
+ ? "var(--accent-glow)"
1390
+ : "var(--bg-input)",
1391
+ color: !passwordField
1392
+ ? "var(--accent-light)"
1393
+ : "var(--text-secondary)",
1394
+ fontWeight: !passwordField ? 600 : 400,
1395
+ transition: "all 0.15s",
1396
+ }}
1397
+ onClick=${() => setPasswordField("")}
1398
+ >
1399
+ None
1400
+ </button>
1401
+ ${registerFields.map(
1402
+ (f) =>
1403
+ html`<button
1404
+ key=${f}
1405
+ style=${{
1406
+ padding: "5px 12px",
1407
+ borderRadius: "6px",
1408
+ fontSize: "12px",
1409
+ cursor: "pointer",
1410
+ border:
1411
+ passwordField === f
1412
+ ? "1.5px solid var(--warning)"
1413
+ : "1.5px solid var(--border)",
1414
+ background:
1415
+ passwordField === f
1416
+ ? "var(--warning-glow)"
1417
+ : "var(--bg-input)",
1418
+ color:
1419
+ passwordField === f
1420
+ ? "var(--warning)"
1421
+ : "var(--text-secondary)",
1422
+ fontWeight: passwordField === f ? 600 : 400,
1423
+ transition: "all 0.15s",
1424
+ }}
1425
+ onClick=${() => setPasswordField(f)}
1426
+ >
1427
+ ${f}
1428
+ </button>`,
1429
+ )}
1430
+ </div>
1431
+ </div>`}
1432
+
1433
+ </div>
1434
+ </div>`;
1435
+ }
1436
+