4bnode 4.0.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/add-login.js +2 -2
- package/index.js +57 -5
- package/lib/indexFile.js +60 -13
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +115 -0
- package/skeleton/.4bnode/assets/js/Root.js +44 -0
- package/skeleton/.4bnode/assets/js/components/Modal.js +52 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +68 -0
- package/skeleton/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/skeleton/.4bnode/assets/js/context.js +7 -0
- package/skeleton/.4bnode/assets/js/lib.js +83 -0
- package/skeleton/.4bnode/assets/js/main.js +5 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +1594 -0
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +461 -0
- package/skeleton/.4bnode/assets/js/pages/EnvironmentPage.js +141 -0
- package/skeleton/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1656 -0
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +901 -0
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +66 -0
- package/skeleton/{dev-api.js → .4bnode/dev-api.js} +259 -114
- package/skeleton/.4bnode/ui.html +1556 -0
- package/skeleton/index.js +62 -16
- package/skeleton/package.json +1 -1
- package/skeleton/public/index.html +290 -7586
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Bold.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Light.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Medium.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Regular.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-SemiBold.woff2 +0 -0
- /package/skeleton/{public → .4bnode/assets}/images/mongo.png +0 -0
- /package/skeleton/{public → .4bnode/assets}/images/mysql.png +0 -0
- /package/skeleton/{public → .4bnode/assets}/images/postgresql.png +0 -0
- /package/skeleton/{public → .4bnode/assets}/images/sqlite.png +0 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { html, api } from "../lib.js";
|
|
3
|
+
import { useApp } from "../context.js";
|
|
4
|
+
|
|
5
|
+
export function DatabasePage() {
|
|
6
|
+
const { data, toast, refresh } = useApp();
|
|
7
|
+
const [selectedDb, setSelectedDb] = useState(
|
|
8
|
+
data.mongoConfigured ? "mongodb" : null,
|
|
9
|
+
);
|
|
10
|
+
const [inputMode, setInputMode] = useState("fields"); // 'fields' or 'url'
|
|
11
|
+
const [uri, setUri] = useState("");
|
|
12
|
+
const [prodUri, setProdUri] = useState("");
|
|
13
|
+
const [host, setHost] = useState("localhost");
|
|
14
|
+
const [port, setPort] = useState("27017");
|
|
15
|
+
const [dbName, setDbName] = useState("myapp");
|
|
16
|
+
const [username, setUsername] = useState("");
|
|
17
|
+
const [password, setPassword] = useState("");
|
|
18
|
+
const [prodHost, setProdHost] = useState("");
|
|
19
|
+
const [prodPort, setProdPort] = useState("");
|
|
20
|
+
const [prodDbName, setProdDbName] = useState("");
|
|
21
|
+
const [prodUsername, setProdUsername] = useState("");
|
|
22
|
+
const [prodPassword, setProdPassword] = useState("");
|
|
23
|
+
const [loading, setLoading] = useState(false);
|
|
24
|
+
const [editing, setEditing] = useState(false);
|
|
25
|
+
|
|
26
|
+
// Parse existing URI when entering edit mode
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (data.mongoConfigured && data.mongoUri) {
|
|
29
|
+
setUri(data.mongoUri);
|
|
30
|
+
setProdUri(data.mongoProdUri || "");
|
|
31
|
+
parseUriToFields(data.mongoUri, false);
|
|
32
|
+
if (data.mongoProdUri && data.mongoProdUri !== data.mongoUri) {
|
|
33
|
+
parseUriToFields(data.mongoProdUri, true);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}, [data.mongoUri]);
|
|
37
|
+
|
|
38
|
+
function parseUriToFields(uriStr, isProd) {
|
|
39
|
+
try {
|
|
40
|
+
const match = uriStr.match(
|
|
41
|
+
/^mongodb(?:\+srv)?:\/\/(?:([^:]+):([^@]+)@)?([^:\/]+)(?::(\d+))?\/(.+?)(?:\?.*)?$/,
|
|
42
|
+
);
|
|
43
|
+
if (match) {
|
|
44
|
+
if (isProd) {
|
|
45
|
+
setProdUsername(match[1] || "");
|
|
46
|
+
setProdPassword(match[2] || "");
|
|
47
|
+
setProdHost(match[3] || "");
|
|
48
|
+
setProdPort(match[4] || "27017");
|
|
49
|
+
setProdDbName(match[5] || "");
|
|
50
|
+
} else {
|
|
51
|
+
setUsername(match[1] || "");
|
|
52
|
+
setPassword(match[2] || "");
|
|
53
|
+
setHost(match[3] || "localhost");
|
|
54
|
+
setPort(match[4] || "27017");
|
|
55
|
+
setDbName(match[5] || "myapp");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildUri(h, p, db, user, pass) {
|
|
62
|
+
const auth =
|
|
63
|
+
user && pass
|
|
64
|
+
? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@`
|
|
65
|
+
: "";
|
|
66
|
+
return `mongodb://${auth}${h || "localhost"}:${p || "27017"}/${db || "myapp"}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getDevUri() {
|
|
70
|
+
return inputMode === "url"
|
|
71
|
+
? (uri || "").trim() || "mongodb://localhost:27017/myapp"
|
|
72
|
+
: buildUri(host, port, dbName, username, password);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getProdUri() {
|
|
76
|
+
if (inputMode === "url") return (prodUri || "").trim() || getDevUri();
|
|
77
|
+
const ph = prodHost || host,
|
|
78
|
+
pp = prodPort || port,
|
|
79
|
+
pd = prodDbName || dbName;
|
|
80
|
+
const pu = prodUsername || username,
|
|
81
|
+
ppw = prodPassword || password;
|
|
82
|
+
return buildUri(ph, pp, pd, pu, ppw);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const databases = [
|
|
86
|
+
{
|
|
87
|
+
id: "mongodb",
|
|
88
|
+
name: "MongoDB",
|
|
89
|
+
desc: "Document database",
|
|
90
|
+
icon: "/_dev/assets/images/mongo.png",
|
|
91
|
+
available: true,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "postgresql",
|
|
95
|
+
name: "PostgreSQL",
|
|
96
|
+
desc: "Relational database",
|
|
97
|
+
icon: "/_dev/assets/images/postgresql.png",
|
|
98
|
+
available: false,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "mysql",
|
|
102
|
+
name: "MySQL",
|
|
103
|
+
desc: "Relational database",
|
|
104
|
+
icon: "/_dev/assets/images/mysql.png",
|
|
105
|
+
available: false,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: "sqlite",
|
|
109
|
+
name: "SQLite",
|
|
110
|
+
desc: "Embedded database",
|
|
111
|
+
icon: "/_dev/assets/images/sqlite.png",
|
|
112
|
+
available: false,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
async function handleSetup() {
|
|
117
|
+
const devUri = getDevUri();
|
|
118
|
+
const pUri = getProdUri();
|
|
119
|
+
if (!devUri.startsWith("mongodb://") && !devUri.startsWith("mongodb+srv://")) {
|
|
120
|
+
toast("Invalid MongoDB URI. Must start with mongodb:// or mongodb+srv://", "error");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
setLoading(true);
|
|
124
|
+
try {
|
|
125
|
+
const isFirstTime = !data.mongoConfigured;
|
|
126
|
+
const result = await api("/add-mongo", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
body: { uri: devUri, prodUri: pUri },
|
|
129
|
+
});
|
|
130
|
+
toast(result.message, "success");
|
|
131
|
+
setEditing(false);
|
|
132
|
+
if (isFirstTime) {
|
|
133
|
+
// Server restarts after first-time setup — wait then retry refresh
|
|
134
|
+
setTimeout(async () => {
|
|
135
|
+
for (let i = 0; i < 10; i++) {
|
|
136
|
+
try {
|
|
137
|
+
await refresh();
|
|
138
|
+
setLoading(false);
|
|
139
|
+
return;
|
|
140
|
+
} catch {}
|
|
141
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
142
|
+
}
|
|
143
|
+
setLoading(false);
|
|
144
|
+
}, 2000);
|
|
145
|
+
} else {
|
|
146
|
+
refresh().catch(() => {});
|
|
147
|
+
setLoading(false);
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
toast(err.message, "error");
|
|
151
|
+
setLoading(false);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Mask password in URI for display
|
|
156
|
+
function maskUri(u) {
|
|
157
|
+
if (!u) return "";
|
|
158
|
+
return u.replace(/:([^@\/]+)@/, ":****@");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const showForm =
|
|
162
|
+
(selectedDb === "mongodb" && !data.mongoConfigured) || editing;
|
|
163
|
+
|
|
164
|
+
const inputModeToggle = html`<div className="flex gap-2 mb-4">
|
|
165
|
+
<button
|
|
166
|
+
className=${`btn btn-sm ${inputMode === "fields" ? "btn-primary" : "btn-ghost"}`}
|
|
167
|
+
onClick=${() => setInputMode("fields")}
|
|
168
|
+
>
|
|
169
|
+
Configuration
|
|
170
|
+
</button>
|
|
171
|
+
<button
|
|
172
|
+
className=${`btn btn-sm ${inputMode === "url" ? "btn-primary" : "btn-ghost"}`}
|
|
173
|
+
onClick=${() => setInputMode("url")}
|
|
174
|
+
>
|
|
175
|
+
Connection URI
|
|
176
|
+
</button>
|
|
177
|
+
</div>`;
|
|
178
|
+
|
|
179
|
+
const urlInputs = html`<fragment>
|
|
180
|
+
<div className="form-group">
|
|
181
|
+
<label className="form-label">Connection URI (Development)</label>
|
|
182
|
+
<input
|
|
183
|
+
className="form-input"
|
|
184
|
+
value=${uri}
|
|
185
|
+
onInput=${(e) => setUri(e.target.value)}
|
|
186
|
+
placeholder="mongodb://username:password@localhost:27017/myapp"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="form-group">
|
|
190
|
+
<label className="form-label"
|
|
191
|
+
>Connection URI (Production) — optional</label
|
|
192
|
+
>
|
|
193
|
+
<input
|
|
194
|
+
className="form-input"
|
|
195
|
+
value=${prodUri}
|
|
196
|
+
onInput=${(e) => setProdUri(e.target.value)}
|
|
197
|
+
placeholder="Same as development if empty"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
</fragment>`;
|
|
201
|
+
|
|
202
|
+
const fieldInputs = html`<fragment>
|
|
203
|
+
<div
|
|
204
|
+
className="card-subtitle mb-3"
|
|
205
|
+
style=${{
|
|
206
|
+
fontSize: "12px",
|
|
207
|
+
textTransform: "uppercase",
|
|
208
|
+
letterSpacing: "0.05em",
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
Development
|
|
212
|
+
</div>
|
|
213
|
+
<div className="grid-2 mb-2">
|
|
214
|
+
<div className="form-group">
|
|
215
|
+
<label className="form-label">Host</label>
|
|
216
|
+
<input
|
|
217
|
+
className="form-input"
|
|
218
|
+
value=${host}
|
|
219
|
+
onInput=${(e) => setHost(e.target.value)}
|
|
220
|
+
placeholder="localhost"
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
<div className="form-group">
|
|
224
|
+
<label className="form-label">Port</label>
|
|
225
|
+
<input
|
|
226
|
+
className="form-input"
|
|
227
|
+
value=${port}
|
|
228
|
+
onInput=${(e) => setPort(e.target.value)}
|
|
229
|
+
placeholder="27017"
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div className="form-group">
|
|
234
|
+
<label className="form-label">Database Name</label>
|
|
235
|
+
<input
|
|
236
|
+
className="form-input"
|
|
237
|
+
value=${dbName}
|
|
238
|
+
onInput=${(e) => setDbName(e.target.value)}
|
|
239
|
+
placeholder="myapp"
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
<div className="grid-2 mb-4">
|
|
243
|
+
<div className="form-group">
|
|
244
|
+
<label className="form-label">Username (optional)</label>
|
|
245
|
+
<input
|
|
246
|
+
className="form-input"
|
|
247
|
+
value=${username}
|
|
248
|
+
onInput=${(e) => setUsername(e.target.value)}
|
|
249
|
+
placeholder="admin"
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
<div className="form-group">
|
|
253
|
+
<label className="form-label">Password (optional)</label>
|
|
254
|
+
<input
|
|
255
|
+
className="form-input"
|
|
256
|
+
type="password"
|
|
257
|
+
value=${password}
|
|
258
|
+
onInput=${(e) => setPassword(e.target.value)}
|
|
259
|
+
placeholder="********"
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<div
|
|
265
|
+
className="card-subtitle mb-3"
|
|
266
|
+
style=${{
|
|
267
|
+
fontSize: "12px",
|
|
268
|
+
textTransform: "uppercase",
|
|
269
|
+
letterSpacing: "0.05em",
|
|
270
|
+
borderTop: "1px solid var(--border)",
|
|
271
|
+
paddingTop: "16px",
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
Production (optional — defaults to dev values)
|
|
275
|
+
</div>
|
|
276
|
+
<div className="grid-2 mb-2">
|
|
277
|
+
<div className="form-group">
|
|
278
|
+
<label className="form-label">Host</label>
|
|
279
|
+
<input
|
|
280
|
+
className="form-input"
|
|
281
|
+
value=${prodHost}
|
|
282
|
+
onInput=${(e) => setProdHost(e.target.value)}
|
|
283
|
+
placeholder=${host || "localhost"}
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
286
|
+
<div className="form-group">
|
|
287
|
+
<label className="form-label">Port</label>
|
|
288
|
+
<input
|
|
289
|
+
className="form-input"
|
|
290
|
+
value=${prodPort}
|
|
291
|
+
onInput=${(e) => setProdPort(e.target.value)}
|
|
292
|
+
placeholder=${port || "27017"}
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
<div className="form-group">
|
|
297
|
+
<label className="form-label">Database Name</label>
|
|
298
|
+
<input
|
|
299
|
+
className="form-input"
|
|
300
|
+
value=${prodDbName}
|
|
301
|
+
onInput=${(e) => setProdDbName(e.target.value)}
|
|
302
|
+
placeholder=${dbName || "myapp"}
|
|
303
|
+
/>
|
|
304
|
+
</div>
|
|
305
|
+
<div className="grid-2">
|
|
306
|
+
<div className="form-group">
|
|
307
|
+
<label className="form-label">Username</label>
|
|
308
|
+
<input
|
|
309
|
+
className="form-input"
|
|
310
|
+
value=${prodUsername}
|
|
311
|
+
onInput=${(e) => setProdUsername(e.target.value)}
|
|
312
|
+
placeholder=${username || ""}
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
<div className="form-group">
|
|
316
|
+
<label className="form-label">Password</label>
|
|
317
|
+
<input
|
|
318
|
+
className="form-input"
|
|
319
|
+
type="password"
|
|
320
|
+
value=${prodPassword}
|
|
321
|
+
onInput=${(e) => setProdPassword(e.target.value)}
|
|
322
|
+
placeholder="********"
|
|
323
|
+
/>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</fragment>`;
|
|
327
|
+
|
|
328
|
+
// Preview the generated URI when in fields mode
|
|
329
|
+
const previewUri =
|
|
330
|
+
inputMode === "fields"
|
|
331
|
+
? html`<div className="form-group" style=${{ marginTop: "8px" }}>
|
|
332
|
+
<label
|
|
333
|
+
className="form-label text-muted"
|
|
334
|
+
style=${{ fontSize: "11px" }}
|
|
335
|
+
>Generated URI</label
|
|
336
|
+
>
|
|
337
|
+
<div
|
|
338
|
+
className="text-sm text-muted"
|
|
339
|
+
style=${{
|
|
340
|
+
fontFamily: "monospace",
|
|
341
|
+
padding: "8px 12px",
|
|
342
|
+
background: "rgba(255,255,255,0.03)",
|
|
343
|
+
borderRadius: "6px",
|
|
344
|
+
wordBreak: "break-all",
|
|
345
|
+
}}
|
|
346
|
+
>
|
|
347
|
+
${maskUri(getDevUri())}
|
|
348
|
+
</div>
|
|
349
|
+
</div>`
|
|
350
|
+
: null;
|
|
351
|
+
|
|
352
|
+
return html`<div>
|
|
353
|
+
${data.mongoConfigured &&
|
|
354
|
+
!editing &&
|
|
355
|
+
html`<div
|
|
356
|
+
className="card mb-6"
|
|
357
|
+
style=${{ borderColor: "rgba(34,197,94,0.3)" }}
|
|
358
|
+
>
|
|
359
|
+
<div className="flex items-center gap-3">
|
|
360
|
+
<img
|
|
361
|
+
src="/_dev/assets/images/mongo.png"
|
|
362
|
+
alt="MongoDB"
|
|
363
|
+
style=${{ width: "28px", height: "28px", objectFit: "contain" }}
|
|
364
|
+
/>
|
|
365
|
+
<div style=${{ flex: 1, minWidth: 0 }}>
|
|
366
|
+
<div style=${{ fontWeight: 600, fontSize: "14px" }}>
|
|
367
|
+
MongoDB Connected
|
|
368
|
+
</div>
|
|
369
|
+
<div
|
|
370
|
+
className="text-sm text-muted"
|
|
371
|
+
style=${{ fontFamily: "monospace", wordBreak: "break-all" }}
|
|
372
|
+
>
|
|
373
|
+
${maskUri(data.mongoUri || "")}
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
<button
|
|
377
|
+
className="btn btn-sm btn-ghost"
|
|
378
|
+
onClick=${() => {
|
|
379
|
+
if (data.mongoUri) {
|
|
380
|
+
setUri(data.mongoUri);
|
|
381
|
+
parseUriToFields(data.mongoUri, false);
|
|
382
|
+
}
|
|
383
|
+
if (data.mongoProdUri) {
|
|
384
|
+
setProdUri(data.mongoProdUri);
|
|
385
|
+
if (data.mongoProdUri !== data.mongoUri)
|
|
386
|
+
parseUriToFields(data.mongoProdUri, true);
|
|
387
|
+
}
|
|
388
|
+
setEditing(true);
|
|
389
|
+
}}
|
|
390
|
+
style=${{ marginLeft: "auto" }}
|
|
391
|
+
>
|
|
392
|
+
Edit
|
|
393
|
+
</button>
|
|
394
|
+
<span className="badge badge-success">✓ Active</span>
|
|
395
|
+
</div>
|
|
396
|
+
</div>`}
|
|
397
|
+
|
|
398
|
+
<div className="card-title mb-3">Select Database</div>
|
|
399
|
+
<div className="card-subtitle mb-4">
|
|
400
|
+
Select which database to use for your project
|
|
401
|
+
</div>
|
|
402
|
+
<div className="grid-4 mb-6">
|
|
403
|
+
${databases.map(
|
|
404
|
+
(db) =>
|
|
405
|
+
html`<div
|
|
406
|
+
key=${db.id}
|
|
407
|
+
className=${`db-card ${selectedDb === db.id ? "active" : ""} ${!db.available ? "disabled" : ""}`}
|
|
408
|
+
onClick=${() => db.available && setSelectedDb(db.id)}
|
|
409
|
+
>
|
|
410
|
+
${!db.available &&
|
|
411
|
+
html`<span className="coming-soon">Coming Soon</span>`}
|
|
412
|
+
<div className="db-icon">
|
|
413
|
+
<img
|
|
414
|
+
src=${db.icon}
|
|
415
|
+
alt=${db.name}
|
|
416
|
+
style=${{
|
|
417
|
+
width: "36px",
|
|
418
|
+
height: "36px",
|
|
419
|
+
objectFit: "contain",
|
|
420
|
+
}}
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
<div className="db-name">${db.name}</div>
|
|
424
|
+
<div className="db-desc">${db.desc}</div>
|
|
425
|
+
</div>`,
|
|
426
|
+
)}
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
${showForm &&
|
|
430
|
+
html`<div className="card">
|
|
431
|
+
<div className="flex items-center gap-3 mb-4">
|
|
432
|
+
<div className="card-title" style=${{ margin: 0 }}>
|
|
433
|
+
${editing ? "Edit Connection" : "MongoDB Setup"}
|
|
434
|
+
</div>
|
|
435
|
+
<div style=${{ marginLeft: "auto", display: "flex", gap: "8px" }}>
|
|
436
|
+
${editing &&
|
|
437
|
+
html`<button
|
|
438
|
+
className="btn btn-sm btn-ghost"
|
|
439
|
+
onClick=${() => setEditing(false)}
|
|
440
|
+
>
|
|
441
|
+
Cancel
|
|
442
|
+
</button>`}
|
|
443
|
+
<button
|
|
444
|
+
className="btn btn-sm btn-primary"
|
|
445
|
+
onClick=${handleSetup}
|
|
446
|
+
disabled=${loading}
|
|
447
|
+
>
|
|
448
|
+
${loading ? html`<span className="spinner" />` : null}
|
|
449
|
+
${loading
|
|
450
|
+
? "Configuring..."
|
|
451
|
+
: editing
|
|
452
|
+
? "Save Changes"
|
|
453
|
+
: "Connect Database"}
|
|
454
|
+
</button>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
${inputModeToggle} ${inputMode === "url" ? urlInputs : fieldInputs}
|
|
458
|
+
${previewUri}
|
|
459
|
+
</div>`}
|
|
460
|
+
</div>`;
|
|
461
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
3
|
+
import { html, api } from "../lib.js";
|
|
4
|
+
import { useApp } from "../context.js";
|
|
5
|
+
|
|
6
|
+
export function EnvironmentPage() {
|
|
7
|
+
const { toast } = useApp();
|
|
8
|
+
const [selected, setSelected] = useState("development");
|
|
9
|
+
const [devContent, setDevContent] = useState("");
|
|
10
|
+
const [prodContent, setProdContent] = useState("");
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [saving, setSaving] = useState(false);
|
|
13
|
+
const [dirty, setDirty] = useState({ development: false, production: false });
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
api("/env")
|
|
17
|
+
.then((res) => {
|
|
18
|
+
setDevContent(res.development || "");
|
|
19
|
+
setProdContent(res.production || "");
|
|
20
|
+
setLoading(false);
|
|
21
|
+
})
|
|
22
|
+
.catch(() => setLoading(false));
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const currentContent = selected === "development" ? devContent : prodContent;
|
|
26
|
+
const setCurrentContent = selected === "development" ? setDevContent : setProdContent;
|
|
27
|
+
|
|
28
|
+
function handleChange(e) {
|
|
29
|
+
setCurrentContent(e.target.value);
|
|
30
|
+
setDirty((d) => ({ ...d, [selected]: true }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function handleSave() {
|
|
34
|
+
setSaving(true);
|
|
35
|
+
try {
|
|
36
|
+
const result = await api("/env", {
|
|
37
|
+
method: "PUT",
|
|
38
|
+
body: { environment: selected, content: currentContent },
|
|
39
|
+
});
|
|
40
|
+
toast(result.message, "success");
|
|
41
|
+
setDirty((d) => ({ ...d, [selected]: false }));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
toast(err.message, "error");
|
|
44
|
+
}
|
|
45
|
+
setSaving(false);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function countVars(content) {
|
|
49
|
+
if (!content) return 0;
|
|
50
|
+
return content.split('\n').filter(l => l.trim() && !l.trim().startsWith('#') && l.includes('=')).length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const files = [
|
|
54
|
+
{ id: "development", label: ".env.development", content: devContent },
|
|
55
|
+
{ id: "production", label: ".env.production", content: prodContent },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return html`<div>
|
|
59
|
+
<div className="flex items-center justify-between mb-4">
|
|
60
|
+
<div>
|
|
61
|
+
<div className="card-title">Environment</div>
|
|
62
|
+
<div className="card-subtitle">Manage environment variables</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="routes-layout">
|
|
67
|
+
<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
|
|
68
|
+
<div style=${{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border)' }}>
|
|
69
|
+
<span style=${{ fontWeight: 600, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Files</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="routes-list">
|
|
72
|
+
${files.map(f => html`<div key=${f.id}
|
|
73
|
+
className=${`route-item ${selected === f.id ? 'active' : ''}`}
|
|
74
|
+
onClick=${() => setSelected(f.id)}>
|
|
75
|
+
<div style=${{ display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 }}>
|
|
76
|
+
<span className="ri-name">${f.label}</span>
|
|
77
|
+
<span className="ri-file">${countVars(f.content)} variable${countVars(f.content) !== 1 ? 's' : ''}</span>
|
|
78
|
+
</div>
|
|
79
|
+
${dirty[f.id] && html`<span className="badge badge-warning" style=${{ fontSize: '10px' }}>Modified</span>`}
|
|
80
|
+
</div>`)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className="routes-detail">
|
|
85
|
+
<div className="card mb-4">
|
|
86
|
+
<div className="flex items-center justify-between mb-3">
|
|
87
|
+
<div>
|
|
88
|
+
<div style=${{ fontWeight: 600, fontSize: '16px', marginBottom: '2px' }}>.env.${selected}</div>
|
|
89
|
+
<div className="text-sm text-muted">${countVars(currentContent)} variable${countVars(currentContent) !== 1 ? 's' : ''} defined</div>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex gap-2 items-center">
|
|
92
|
+
${dirty[selected] && html`<span className="text-sm text-muted" style=${{ fontSize: '11px' }}>Unsaved</span>`}
|
|
93
|
+
<button className="btn btn-sm btn-primary" onClick=${handleSave} disabled=${saving || !dirty[selected]}>
|
|
94
|
+
${saving ? html`<span className="spinner" />` : null}
|
|
95
|
+
${saving ? "Saving..." : "Save"}
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
|
|
102
|
+
<div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
103
|
+
<span style=${{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)' }}>Editor</span>
|
|
104
|
+
<span className="field-count" style=${{ fontSize: '11px' }}>Cmd+S to save</span>
|
|
105
|
+
</div>
|
|
106
|
+
<${AnimatePresence} mode="wait">
|
|
107
|
+
<${motion.div} key=${selected}
|
|
108
|
+
initial=${{ opacity: 0, y: 6 }} animate=${{ opacity: 1, y: 0 }}
|
|
109
|
+
exit=${{ opacity: 0, y: -6 }} transition=${{ duration: 0.12 }}>
|
|
110
|
+
${loading
|
|
111
|
+
? html`<div className="text-sm text-muted" style=${{ padding: '40px 0', textAlign: 'center' }}>Loading...</div>`
|
|
112
|
+
: html`<textarea
|
|
113
|
+
className="env-editor"
|
|
114
|
+
value=${currentContent}
|
|
115
|
+
onInput=${handleChange}
|
|
116
|
+
placeholder=${"# Add environment variables\n# KEY=value"}
|
|
117
|
+
spellCheck=${false}
|
|
118
|
+
style=${{ borderRadius: 0, border: 'none' }}
|
|
119
|
+
onKeyDown=${(e) => {
|
|
120
|
+
if (e.key === "Tab") {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
const start = e.target.selectionStart;
|
|
123
|
+
const end = e.target.selectionEnd;
|
|
124
|
+
const val = e.target.value;
|
|
125
|
+
setCurrentContent(val.substring(0, start) + " " + val.substring(end));
|
|
126
|
+
setTimeout(() => { e.target.selectionStart = e.target.selectionEnd = start + 2; }, 0);
|
|
127
|
+
}
|
|
128
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
if (dirty[selected]) handleSave();
|
|
131
|
+
}
|
|
132
|
+
}}
|
|
133
|
+
/>`}
|
|
134
|
+
<//>
|
|
135
|
+
<//>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>`;
|
|
140
|
+
}
|
|
141
|
+
|