4bnode 3.5.3 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +144 -109
  2. package/add-api.js +288 -156
  3. package/add-crud.js +226 -0
  4. package/add-login.js +152 -319
  5. package/add-mongo-delete.js +67 -138
  6. package/add-mongo-insert.js +87 -204
  7. package/add-mongo-read.js +100 -216
  8. package/add-mongo-schema.js +431 -199
  9. package/add-mongo-update.js +89 -202
  10. package/add-mongo.js +125 -218
  11. package/add-serialport.js +53 -98
  12. package/add-socket.js +43 -66
  13. package/add-websocket.js +68 -140
  14. package/index.js +341 -156
  15. package/lib/env.js +18 -0
  16. package/lib/indexFile.js +134 -0
  17. package/lib/names.js +21 -0
  18. package/lib/project.js +122 -0
  19. package/lib/routeFile.js +56 -0
  20. package/lib/ui.js +161 -0
  21. package/package.json +9 -16
  22. package/skeleton/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  23. package/skeleton/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  24. package/skeleton/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  25. package/skeleton/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  26. package/skeleton/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  27. package/skeleton/.4bnode/assets/images/mongo.png +0 -0
  28. package/skeleton/.4bnode/assets/images/mysql.png +0 -0
  29. package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
  30. package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
  31. package/skeleton/.4bnode/assets/js/App.js +115 -0
  32. package/skeleton/.4bnode/assets/js/Root.js +44 -0
  33. package/skeleton/.4bnode/assets/js/components/Modal.js +52 -0
  34. package/skeleton/.4bnode/assets/js/components/Sidebar.js +68 -0
  35. package/skeleton/.4bnode/assets/js/components/ToastContainer.js +15 -0
  36. package/skeleton/.4bnode/assets/js/context.js +7 -0
  37. package/skeleton/.4bnode/assets/js/lib.js +83 -0
  38. package/skeleton/.4bnode/assets/js/main.js +5 -0
  39. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +1594 -0
  40. package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +435 -0
  41. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +461 -0
  42. package/skeleton/.4bnode/assets/js/pages/EnvironmentPage.js +141 -0
  43. package/skeleton/.4bnode/assets/js/pages/RealtimePage.js +186 -0
  44. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1656 -0
  45. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +901 -0
  46. package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
  47. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
  48. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +66 -0
  49. package/skeleton/.4bnode/dev-api.js +2930 -0
  50. package/skeleton/.4bnode/ui.html +1556 -0
  51. package/skeleton/index.js +70 -17
  52. package/skeleton/package.json +9 -5
  53. package/skeleton/public/index.html +288 -86
  54. package/README_4bnode.md +0 -197
  55. package/src/auth/passport.js +0 -21
  56. package/src/db.js +0 -13
  57. package/test-server/.env.development +0 -1
  58. package/test-server/.env.production +0 -1
  59. package/test-server/index.js +0 -82
  60. package/test-server/package-lock.json +0 -1267
  61. package/test-server/package.json +0 -20
  62. package/test-server/public/index.html +0 -90
@@ -0,0 +1,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
+