4bnode 4.1.2 → 4.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/add-security.js +0 -18
- package/app-1/.4bnode/.passkey +1 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +139 -0
- package/app-1/.4bnode/assets/js/Root.js +40 -0
- package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
- package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/app-1/.4bnode/assets/js/context.js +7 -0
- package/app-1/.4bnode/assets/js/lib.js +113 -0
- package/app-1/.4bnode/assets/js/main.js +5 -0
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/app-1/.4bnode/codegen.js +1249 -0
- package/app-1/.4bnode/dev-api.js +3621 -0
- package/app-1/.4bnode/ui.html +1578 -0
- package/app-1/README.md +47 -0
- package/app-1/ecosystem.config.cjs +25 -0
- package/app-1/index.js +145 -0
- package/app-1/package.json +35 -0
- package/app-1/public/index.html +292 -0
- package/app-1/src/db.js +13 -0
- package/app-1/src/docs.js +84 -0
- package/app-1/src/middleware/errorHandler.js +15 -0
- package/app-1/src/middleware/roles.js +14 -0
- package/app-1/src/middleware/validate.js +20 -0
- package/app-1/src/models/login.js +9 -0
- package/app-1/src/openapi.json +62 -0
- package/app-1/src/routes/products.js +26 -0
- package/app-1/src/validators/products.js +7 -0
- package/app-1/src/validators/user.js +7 -0
- package/index.js +2 -17
- package/lib/codegen.js +69 -43
- package/lib/codegen.test.js +21 -2
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +0 -2
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
- package/skeleton/.4bnode/assets/js/lib.js +0 -3
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
- package/skeleton/.4bnode/codegen.js +69 -43
- package/skeleton/.4bnode/dev-api.js +173 -87
- package/skeleton/_gitignore +0 -1
- package/skeleton/index.js +4 -8
- 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
|
+
|