4bnode 4.0.0 → 4.0.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.
Files changed (39) hide show
  1. package/README.md +10 -0
  2. package/add-login.js +2 -2
  3. package/add-serialport.js +1 -1
  4. package/index.js +60 -6
  5. package/lib/indexFile.js +60 -13
  6. package/lib/ui.js +1 -0
  7. package/package.json +1 -1
  8. package/skeleton/.4bnode/assets/js/App.js +115 -0
  9. package/skeleton/.4bnode/assets/js/Root.js +44 -0
  10. package/skeleton/.4bnode/assets/js/components/Modal.js +52 -0
  11. package/skeleton/.4bnode/assets/js/components/Sidebar.js +68 -0
  12. package/skeleton/.4bnode/assets/js/components/ToastContainer.js +15 -0
  13. package/skeleton/.4bnode/assets/js/context.js +7 -0
  14. package/skeleton/.4bnode/assets/js/lib.js +83 -0
  15. package/skeleton/.4bnode/assets/js/main.js +5 -0
  16. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +1594 -0
  17. package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +435 -0
  18. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +461 -0
  19. package/skeleton/.4bnode/assets/js/pages/EnvironmentPage.js +141 -0
  20. package/skeleton/.4bnode/assets/js/pages/RealtimePage.js +186 -0
  21. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1656 -0
  22. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +901 -0
  23. package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
  24. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
  25. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +66 -0
  26. package/skeleton/{dev-api.js → .4bnode/dev-api.js} +259 -114
  27. package/skeleton/.4bnode/ui.html +1556 -0
  28. package/skeleton/index.js +62 -16
  29. package/skeleton/package.json +1 -1
  30. package/skeleton/public/index.html +290 -7586
  31. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Bold.woff2 +0 -0
  32. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Light.woff2 +0 -0
  33. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Medium.woff2 +0 -0
  34. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-Regular.woff2 +0 -0
  35. /package/skeleton/{public → .4bnode/assets}/fonts/NotoSans-SemiBold.woff2 +0 -0
  36. /package/skeleton/{public → .4bnode/assets}/images/mongo.png +0 -0
  37. /package/skeleton/{public → .4bnode/assets}/images/mysql.png +0 -0
  38. /package/skeleton/{public → .4bnode/assets}/images/postgresql.png +0 -0
  39. /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
+