4bnode 4.1.5 → 4.1.6

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 (68) hide show
  1. package/add-api.js +5 -3
  2. package/add-email.js +118 -28
  3. package/add-login.js +35 -16
  4. package/add-mongo-schema.js +13 -5
  5. package/lib/codegen.js +191 -57
  6. package/lib/codegen.test.js +87 -15
  7. package/lib/names.js +1 -1
  8. package/package.json +1 -1
  9. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
  10. package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
  11. package/skeleton/.4bnode/assets/js/pages/MailPage.js +110 -50
  12. package/skeleton/.4bnode/codegen.js +191 -57
  13. package/skeleton/.4bnode/dev-api.js +284 -56
  14. package/skeleton/.env.example +17 -3
  15. package/skeleton/_gitignore +1 -0
  16. package/skeleton/index.js +7 -2
  17. package/skeleton/src/middleware/apiKey.js +14 -1
  18. package/skeleton/src/middleware/errorHandler.js +7 -2
  19. package/app-1/.4bnode/.passkey +0 -1
  20. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  21. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  22. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  23. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  24. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  25. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  26. package/app-1/.4bnode/assets/js/App.js +0 -139
  27. package/app-1/.4bnode/assets/js/Root.js +0 -40
  28. package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
  29. package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
  30. package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
  31. package/app-1/.4bnode/assets/js/context.js +0 -7
  32. package/app-1/.4bnode/assets/js/lib.js +0 -113
  33. package/app-1/.4bnode/assets/js/main.js +0 -5
  34. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
  35. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
  36. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
  37. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
  38. package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
  39. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
  40. package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
  41. package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -224
  42. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
  43. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
  44. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
  45. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
  46. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
  47. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
  48. package/app-1/.4bnode/codegen.js +0 -1440
  49. package/app-1/.4bnode/dev-api.js +0 -3747
  50. package/app-1/.4bnode/ui.html +0 -1578
  51. package/app-1/.env.example +0 -11
  52. package/app-1/README.md +0 -47
  53. package/app-1/ecosystem.config.cjs +0 -25
  54. package/app-1/index.js +0 -188
  55. package/app-1/package.json +0 -37
  56. package/app-1/public/index.html +0 -292
  57. package/app-1/src/db.js +0 -13
  58. package/app-1/src/docs.js +0 -84
  59. package/app-1/src/middleware/apiKey.js +0 -36
  60. package/app-1/src/middleware/auth.js +0 -17
  61. package/app-1/src/middleware/errorHandler.js +0 -15
  62. package/app-1/src/middleware/roles.js +0 -14
  63. package/app-1/src/middleware/validate.js +0 -20
  64. package/app-1/src/models/user.js +0 -11
  65. package/app-1/src/openapi.json +0 -80
  66. package/app-1/src/routes/upload-resume.js +0 -21
  67. package/app-1/src/services/mailer.js +0 -52
  68. package/app-1/src/validators/upload-resume.js +0 -8
@@ -1,300 +0,0 @@
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, status, toast, refresh } = useApp();
7
- // MongoDB is the only database, so the page shows its configuration directly
8
- // (no database picker).
9
- // Default the database name to the app's folder name (provided by the server).
10
- const appName = status?.appName || "myapp";
11
- const [inputMode, setInputMode] = useState("fields"); // 'fields' or 'url'
12
- const [uri, setUri] = useState("");
13
- const [host, setHost] = useState("localhost");
14
- const [port, setPort] = useState("27017");
15
- const [dbName, setDbName] = useState("");
16
- const [dbNameTouched, setDbNameTouched] = useState(false);
17
- const [username, setUsername] = useState("");
18
- const [password, setPassword] = useState("");
19
- const [loading, setLoading] = useState(false);
20
- const [editing, setEditing] = useState(false);
21
-
22
- // Prefill the db name with the folder name once status loads — unless the user
23
- // edited it, or we're editing an already-configured connection.
24
- useEffect(() => {
25
- if (!dbNameTouched && !data.mongoConfigured && status?.appName) {
26
- setDbName(status.appName);
27
- }
28
- }, [status?.appName, data.mongoConfigured, dbNameTouched]);
29
-
30
- // Parse existing URI when entering edit mode
31
- useEffect(() => {
32
- if (data.mongoConfigured && data.mongoUri) {
33
- setUri(data.mongoUri);
34
- parseUriToFields(data.mongoUri);
35
- }
36
- }, [data.mongoUri]);
37
-
38
- function parseUriToFields(uriStr) {
39
- try {
40
- const match = uriStr.match(
41
- /^mongodb(?:\+srv)?:\/\/(?:([^:]+):([^@]+)@)?([^:\/]+)(?::(\d+))?\/(.+?)(?:\?.*)?$/,
42
- );
43
- if (match) {
44
- setUsername(match[1] || "");
45
- setPassword(match[2] || "");
46
- setHost(match[3] || "localhost");
47
- setPort(match[4] || "27017");
48
- setDbName(match[5] || appName);
49
- }
50
- } catch {}
51
- }
52
-
53
- function buildUri(h, p, db, user, pass) {
54
- const auth =
55
- user && pass
56
- ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@`
57
- : "";
58
- return `mongodb://${auth}${h || "localhost"}:${p || "27017"}/${db || appName}`;
59
- }
60
-
61
- function getUri() {
62
- return inputMode === "url"
63
- ? (uri || "").trim() || `mongodb://localhost:27017/${appName}`
64
- : buildUri(host, port, dbName, username, password);
65
- }
66
-
67
- async function handleSetup() {
68
- const connUri = getUri();
69
- if (!connUri.startsWith("mongodb://") && !connUri.startsWith("mongodb+srv://")) {
70
- toast("Invalid MongoDB URI. Must start with mongodb:// or mongodb+srv://", "error");
71
- return;
72
- }
73
- setLoading(true);
74
- try {
75
- const isFirstTime = !data.mongoConfigured;
76
- const result = await api("/add-mongo", {
77
- method: "POST",
78
- body: { uri: connUri },
79
- });
80
- toast(result.message, "success");
81
- setEditing(false);
82
- if (isFirstTime) {
83
- // Server restarts after first-time setup — wait then retry refresh
84
- setTimeout(async () => {
85
- for (let i = 0; i < 10; i++) {
86
- try {
87
- await refresh();
88
- setLoading(false);
89
- return;
90
- } catch {}
91
- await new Promise((r) => setTimeout(r, 1500));
92
- }
93
- setLoading(false);
94
- }, 2000);
95
- } else {
96
- refresh().catch(() => {});
97
- setLoading(false);
98
- }
99
- } catch (err) {
100
- toast(err.message, "error");
101
- setLoading(false);
102
- }
103
- }
104
-
105
- // Mask password in URI for display
106
- function maskUri(u) {
107
- if (!u) return "";
108
- return u.replace(/:([^@\/]+)@/, ":****@");
109
- }
110
-
111
- const showForm = !data.mongoConfigured || editing;
112
-
113
- const inputModeToggle = html`<div className="flex gap-2 mb-4">
114
- <button
115
- className=${`btn btn-sm ${inputMode === "fields" ? "btn-primary" : "btn-ghost"}`}
116
- onClick=${() => setInputMode("fields")}
117
- >
118
- Configuration
119
- </button>
120
- <button
121
- className=${`btn btn-sm ${inputMode === "url" ? "btn-primary" : "btn-ghost"}`}
122
- onClick=${() => setInputMode("url")}
123
- >
124
- Connection URI
125
- </button>
126
- </div>`;
127
-
128
- const urlInputs = html`<fragment>
129
- <div className="form-group">
130
- <label className="form-label">Connection URI</label>
131
- <input
132
- className="form-input"
133
- value=${uri}
134
- onInput=${(e) => setUri(e.target.value)}
135
- placeholder="mongodb://username:password@localhost:27017/myapp"
136
- />
137
- </div>
138
- </fragment>`;
139
-
140
- const fieldInputs = html`<fragment>
141
- <div className="grid-2 mb-2">
142
- <div className="form-group">
143
- <label className="form-label">Host</label>
144
- <input
145
- className="form-input"
146
- value=${host}
147
- onInput=${(e) => setHost(e.target.value)}
148
- placeholder="localhost"
149
- />
150
- </div>
151
- <div className="form-group">
152
- <label className="form-label">Port</label>
153
- <input
154
- className="form-input"
155
- value=${port}
156
- onInput=${(e) => setPort(e.target.value)}
157
- placeholder="27017"
158
- />
159
- </div>
160
- </div>
161
- <div className="form-group">
162
- <label className="form-label">Database Name</label>
163
- <input
164
- className="form-input"
165
- value=${dbName}
166
- onInput=${(e) => { setDbNameTouched(true); setDbName(e.target.value); }}
167
- placeholder=${appName}
168
- />
169
- </div>
170
- <div className="grid-2 mb-4">
171
- <div className="form-group">
172
- <label className="form-label">Username (optional)</label>
173
- <input
174
- className="form-input"
175
- value=${username}
176
- onInput=${(e) => setUsername(e.target.value)}
177
- placeholder="admin"
178
- />
179
- </div>
180
- <div className="form-group">
181
- <label className="form-label">Password (optional)</label>
182
- <input
183
- className="form-input"
184
- type="password"
185
- value=${password}
186
- onInput=${(e) => setPassword(e.target.value)}
187
- placeholder="********"
188
- />
189
- </div>
190
- </div>
191
- </fragment>`;
192
-
193
- // Preview the generated URI when in fields mode
194
- const previewUri =
195
- inputMode === "fields"
196
- ? html`<div className="form-group" style=${{ marginTop: "8px" }}>
197
- <label
198
- className="form-label text-muted"
199
- style=${{ fontSize: "11px" }}
200
- >Generated URI</label
201
- >
202
- <div
203
- className="text-sm text-muted"
204
- style=${{
205
- fontFamily: "monospace",
206
- padding: "8px 12px",
207
- background: "rgba(255,255,255,0.03)",
208
- borderRadius: "6px",
209
- wordBreak: "break-all",
210
- }}
211
- >
212
- ${maskUri(getUri())}
213
- </div>
214
- </div>`
215
- : null;
216
-
217
- return html`<div>
218
- <div className="card-title mb-1">Database</div>
219
- <div className="card-subtitle mb-4">
220
- Configure the MongoDB connection for your project
221
- </div>
222
-
223
- ${data.mongoConfigured &&
224
- !editing &&
225
- html`<div
226
- className="card mb-6"
227
- style=${{ borderColor: "rgba(34,197,94,0.3)" }}
228
- >
229
- <div className="flex items-center gap-3">
230
- <img
231
- src="/_dev/assets/images/mongo.png"
232
- alt="MongoDB"
233
- style=${{ width: "28px", height: "28px", objectFit: "contain" }}
234
- />
235
- <div style=${{ flex: 1, minWidth: 0 }}>
236
- <div style=${{ fontWeight: 600, fontSize: "14px" }}>
237
- MongoDB Connected
238
- </div>
239
- <div
240
- className="text-sm text-muted"
241
- style=${{ fontFamily: "monospace", wordBreak: "break-all" }}
242
- >
243
- ${maskUri(data.mongoUri || "")}
244
- </div>
245
- </div>
246
- <button
247
- className="btn btn-sm btn-ghost"
248
- onClick=${() => {
249
- if (data.mongoUri) {
250
- setUri(data.mongoUri);
251
- parseUriToFields(data.mongoUri);
252
- }
253
- setEditing(true);
254
- }}
255
- style=${{ marginLeft: "auto" }}
256
- >
257
- Edit
258
- </button>
259
- <span className="badge badge-success">✓ Active</span>
260
- </div>
261
- </div>`}
262
-
263
- ${showForm &&
264
- html`<div className="card">
265
- <div className="flex items-center gap-3 mb-4">
266
- <img
267
- src="/_dev/assets/images/mongo.png"
268
- alt="MongoDB"
269
- style=${{ width: "24px", height: "24px", objectFit: "contain" }}
270
- />
271
- <div className="card-title" style=${{ margin: 0 }}>
272
- ${editing ? "Edit Connection" : "MongoDB Setup"}
273
- </div>
274
- <div style=${{ marginLeft: "auto", display: "flex", gap: "8px" }}>
275
- ${editing &&
276
- html`<button
277
- className="btn btn-sm btn-ghost"
278
- onClick=${() => setEditing(false)}
279
- >
280
- Cancel
281
- </button>`}
282
- <button
283
- className="btn btn-sm btn-primary"
284
- onClick=${handleSetup}
285
- disabled=${loading}
286
- >
287
- ${loading ? html`<span className="spinner" />` : null}
288
- ${loading
289
- ? "Configuring..."
290
- : editing
291
- ? "Save Changes"
292
- : "Connect Database"}
293
- </button>
294
- </div>
295
- </div>
296
- ${inputModeToggle} ${inputMode === "url" ? urlInputs : fieldInputs}
297
- ${previewUri}
298
- </div>`}
299
- </div>`;
300
- }
@@ -1,150 +0,0 @@
1
- import React, { useState, useEffect, useCallback } from "react";
2
- import { LuFileText, LuCheck, LuRefreshCw, LuExternalLink } from "react-icons/lu";
3
- import { html, api } from "../lib.js";
4
- import { useApp } from "../context.js";
5
-
6
- export function DocsPage() {
7
- const { toast, refresh } = useApp();
8
- const [info, setInfo] = useState(null);
9
- const [busy, setBusy] = useState(false);
10
- const [pw, setPw] = useState("");
11
- const [pwBusy, setPwBusy] = useState(false);
12
-
13
- const load = useCallback(async () => {
14
- // Docs setup/regenerate writes src/docs.js → nodemon restart. Retry across the
15
- // restart window so the re-fetch doesn't surface as a "NetworkError".
16
- let lastErr;
17
- for (let i = 0; i < 8; i++) {
18
- try { setInfo(await api("/docs")); return; }
19
- catch (err) { lastErr = err; await new Promise((r) => setTimeout(r, 500)); }
20
- }
21
- if (lastErr) toast(lastErr.message, "error");
22
- }, [toast]);
23
-
24
- useEffect(() => { load(); }, [load]);
25
-
26
- const docsUrl = (() => {
27
- const port = window.location.port || "3000";
28
- return window.location.protocol + "//" + window.location.hostname + ":" + port + "/docs";
29
- })();
30
-
31
- async function setup() {
32
- if (pw && pw.length < 6) {
33
- toast("Password must be at least 6 characters (or leave blank for public docs)", "error");
34
- return;
35
- }
36
- setBusy(true);
37
- try {
38
- // Set the password first (writes docs-auth.json), then create the docs route —
39
- // so /docs is protected from the moment it goes live.
40
- if (pw) await api("/docs/password", { method: "POST", body: { password: pw } });
41
- await api("/docs/setup", { method: "POST" });
42
- toast(pw ? "API docs set up and password-protected." : "API docs set up at /docs.", "success");
43
- setPw("");
44
- await load();
45
- refresh().catch(() => {});
46
- } catch (err) { toast(err.message, "error"); }
47
- setBusy(false);
48
- }
49
-
50
- async function regenerate() {
51
- setBusy(true);
52
- try {
53
- const r = await api("/docs/regenerate", { method: "POST" });
54
- toast(r.message, "success");
55
- await load();
56
- } catch (err) { toast(err.message, "error"); }
57
- setBusy(false);
58
- }
59
-
60
- async function setPassword() {
61
- if (pw.length < 6) { toast("Password must be at least 6 characters", "error"); return; }
62
- setPwBusy(true);
63
- try {
64
- const r = await api("/docs/password", { method: "POST", body: { password: pw } });
65
- toast(r.message, "success");
66
- setPw("");
67
- await load();
68
- } catch (err) { toast(err.message, "error"); }
69
- setPwBusy(false);
70
- }
71
-
72
- if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
73
-
74
- return html`<div>
75
- <div className="flex items-center justify-between mb-4">
76
- <div>
77
- <div className="card-title">API Documentation</div>
78
- <div className="card-subtitle">Interactive API docs generated from your routes and models</div>
79
- </div>
80
- </div>
81
-
82
- ${!info.configured
83
- ? html`<div className="card" style=${{ maxWidth: "560px" }}>
84
- <div className="flex items-center gap-3 mb-4">
85
- <${LuFileText} size=${20} color="var(--accent)" />
86
- <div>
87
- <div style=${{ fontWeight: 600 }}>Set up API docs</div>
88
- <div className="text-sm text-muted">Generates a spec from your ${info.endpointCount} endpoint${info.endpointCount === 1 ? "" : "s"} and serves an interactive docs page at <span style=${{ fontFamily: "monospace" }}>/docs</span>.</div>
89
- </div>
90
- </div>
91
- <div className="form-group">
92
- <label className="form-label">Docs password</label>
93
- <input className="form-input" type="password" value=${pw}
94
- placeholder="Protect /docs (min 6 chars) — leave blank for public docs"
95
- onInput=${(e) => setPw(e.target.value)}
96
- onKeyDown=${(e) => { if (e.key === "Enter") setup(); }} />
97
- </div>
98
- <button className="btn btn-primary" onClick=${setup} disabled=${busy}>
99
- ${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
100
- ${busy ? "Setting up..." : "Set up API docs"}
101
- </button>
102
- </div>`
103
- : html`<div>
104
- <div className="card mb-4">
105
- <div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
106
- <${LuFileText} size=${20} color="var(--success)" />
107
- <div style=${{ flex: 1, minWidth: "180px" }}>
108
- <div style=${{ fontWeight: 600 }}>Docs are live</div>
109
- <div className="text-sm text-muted">${info.endpointCount} endpoint${info.endpointCount === 1 ? "" : "s"} documented · spec at <span style=${{ fontFamily: "monospace" }}>src/openapi.json</span></div>
110
- </div>
111
- <a className="btn" href=${docsUrl} target="_blank" rel="noreferrer" style=${{ textDecoration: "none" }}>
112
- <${LuExternalLink} size=${14} /> Open /docs
113
- </a>
114
- <button className="btn btn-primary" onClick=${regenerate} disabled=${busy}>
115
- ${busy ? html`<span className="spinner" />` : html`<${LuRefreshCw} size=${14} />`}
116
- Regenerate
117
- </button>
118
- </div>
119
- </div>
120
- <div className="card mb-4">
121
- <div className="card-title mb-1">Docs access</div>
122
- <div className="card-subtitle mb-4">
123
- By default <span style=${{ fontFamily: "monospace" }}>/docs</span> is public — anyone who can reach the app can read it.
124
- Set a password to require a login before the docs are shown.
125
- </div>
126
- ${info.passwordProtected
127
- ? html`<div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
128
- <span className="badge badge-success">🔒 Password protected</span>
129
- <div style=${{ display: "flex", gap: "8px", marginLeft: "auto", flexWrap: "wrap" }}>
130
- <input className="form-input" type="password" placeholder="New password" value=${pw}
131
- onInput=${(e) => setPw(e.target.value)} style=${{ width: "200px" }} />
132
- <button className="btn btn-sm btn-primary" onClick=${setPassword} disabled=${pwBusy || pw.length < 6}>Change</button>
133
- </div>
134
- </div>`
135
- : html`<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
136
- <input className="form-input" type="password" placeholder="Password (min 6 chars)" value=${pw}
137
- onInput=${(e) => setPw(e.target.value)}
138
- onKeyDown=${(e) => { if (e.key === "Enter") setPassword(); }}
139
- style=${{ flex: 1, minWidth: "220px" }} />
140
- <button className="btn btn-primary" onClick=${setPassword} disabled=${pwBusy || pw.length < 6}>
141
- ${pwBusy ? html`<span className="spinner" />` : null} Set password
142
- </button>
143
- </div>`}
144
- </div>
145
- <div className="card text-sm text-muted">
146
- The spec stays in sync automatically — it regenerates whenever you add or change routes and models. Use Regenerate to force a refresh.
147
- </div>
148
- </div>`}
149
- </div>`;
150
- }
@@ -1,103 +0,0 @@
1
- import React, { useState, useEffect } from "react";
2
- import { motion } 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 [content, setContent] = useState("");
9
- const [loading, setLoading] = useState(true);
10
- const [saving, setSaving] = useState(false);
11
- const [dirty, setDirty] = useState(false);
12
-
13
- useEffect(() => {
14
- api("/env")
15
- .then((res) => {
16
- setContent(res.content || "");
17
- setLoading(false);
18
- })
19
- .catch(() => setLoading(false));
20
- }, []);
21
-
22
- function handleChange(e) {
23
- setContent(e.target.value);
24
- setDirty(true);
25
- }
26
-
27
- async function handleSave() {
28
- setSaving(true);
29
- try {
30
- const result = await api("/env", {
31
- method: "PUT",
32
- body: { content },
33
- });
34
- toast(result.message, "success");
35
- setDirty(false);
36
- } catch (err) {
37
- toast(err.message, "error");
38
- }
39
- setSaving(false);
40
- }
41
-
42
- function countVars(c) {
43
- if (!c) return 0;
44
- return c.split('\n').filter(l => l.trim() && !l.trim().startsWith('#') && l.includes('=')).length;
45
- }
46
-
47
- return html`<div>
48
- <div className="flex items-center justify-between mb-4">
49
- <div>
50
- <div className="card-title">Environment</div>
51
- <div className="card-subtitle">Manage environment variables</div>
52
- </div>
53
- </div>
54
-
55
- <div className="card mb-4">
56
- <div className="flex items-center justify-between mb-3">
57
- <div>
58
- <div style=${{ fontWeight: 600, fontSize: '16px', marginBottom: '2px' }}>.env</div>
59
- <div className="text-sm text-muted">${() => countVars(content)} variable${() => countVars(content) !== 1 ? 's' : ''} defined</div>
60
- </div>
61
- <div className="flex gap-2 items-center">
62
- ${() => dirty && html`<span className="text-sm text-muted" style=${{ fontSize: '11px' }}>Unsaved</span>`}
63
- <button className="btn btn-sm btn-primary" onClick=${handleSave} disabled=${saving || !dirty}>
64
- ${saving ? html`<span className="spinner" />` : null}
65
- ${saving ? "Saving..." : "Save"}
66
- </button>
67
- </div>
68
- </div>
69
- </div>
70
-
71
- <div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
72
- <div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
73
- <span style=${{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)' }}>Editor</span>
74
- <span className="field-count" style=${{ fontSize: '11px' }}>Cmd+S to save</span>
75
- </div>
76
- <${motion.div} initial=${{ opacity: 0, y: 6 }} animate=${{ opacity: 1, y: 0 }} transition=${{ duration: 0.12 }}>
77
- ${loading
78
- ? html`<div className="text-sm text-muted" style=${{ padding: '40px 0', textAlign: 'center' }}>Loading...</div>`
79
- : html`<textarea
80
- className="env-editor"
81
- value=${content}
82
- onInput=${handleChange}
83
- placeholder=${"# Add environment variables\n# KEY=value"}
84
- spellCheck=${false}
85
- style=${{ borderRadius: 0, border: 'none' }}
86
- onKeyDown=${(e) => {
87
- if (e.key === "Tab") {
88
- e.preventDefault();
89
- const start = e.target.selectionStart;
90
- const end = e.target.selectionEnd;
91
- setContent(e.target.value.substring(0, start) + " " + e.target.value.substring(end));
92
- setTimeout(() => { e.target.selectionStart = e.target.selectionEnd = start + 2; }, 0);
93
- }
94
- if ((e.metaKey || e.ctrlKey) && e.key === "s") {
95
- e.preventDefault();
96
- if (dirty) handleSave();
97
- }
98
- }}
99
- />`}
100
- <//>
101
- </div>
102
- </div>`;
103
- }