4bnode 4.1.5 → 4.1.7
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-api.js +5 -3
- package/add-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +191 -57
- package/lib/codegen.test.js +87 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +110 -50
- package/skeleton/.4bnode/codegen.js +191 -57
- package/skeleton/.4bnode/dev-api.js +284 -56
- package/skeleton/.env.example +17 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +27 -4
- package/skeleton/src/middleware/apiKey.js +14 -1
- package/skeleton/src/middleware/errorHandler.js +7 -2
- package/app-1/.4bnode/.passkey +0 -1
- 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 +0 -139
- package/app-1/.4bnode/assets/js/Root.js +0 -40
- package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
- package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
- package/app-1/.4bnode/assets/js/context.js +0 -7
- package/app-1/.4bnode/assets/js/lib.js +0 -113
- package/app-1/.4bnode/assets/js/main.js +0 -5
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
- package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -224
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
- package/app-1/.4bnode/codegen.js +0 -1440
- package/app-1/.4bnode/dev-api.js +0 -3747
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/.env.example +0 -11
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -188
- package/app-1/package.json +0 -37
- package/app-1/public/index.html +0 -292
- package/app-1/src/db.js +0 -13
- package/app-1/src/docs.js +0 -84
- package/app-1/src/middleware/apiKey.js +0 -36
- package/app-1/src/middleware/auth.js +0 -17
- package/app-1/src/middleware/errorHandler.js +0 -15
- package/app-1/src/middleware/roles.js +0 -14
- package/app-1/src/middleware/validate.js +0 -20
- package/app-1/src/models/user.js +0 -11
- package/app-1/src/openapi.json +0 -80
- package/app-1/src/routes/upload-resume.js +0 -21
- package/app-1/src/services/mailer.js +0 -52
- package/app-1/src/validators/upload-resume.js +0 -8
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
-
import { html, api, API, getStoredPasskey } from "../lib.js";
|
|
3
|
-
import { useApp } from "../context.js";
|
|
4
|
-
|
|
5
|
-
const TYPE_COLORS = {
|
|
6
|
-
log: "var(--text)",
|
|
7
|
-
info: "var(--info)",
|
|
8
|
-
warn: "var(--warning)",
|
|
9
|
-
error: "var(--error)",
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const TYPE_BADGES = {
|
|
13
|
-
log: "LOG",
|
|
14
|
-
info: "INFO",
|
|
15
|
-
warn: "WARN",
|
|
16
|
-
error: "ERR",
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export function LogsPage() {
|
|
20
|
-
const { toast } = useApp();
|
|
21
|
-
const [logs, setLogs] = useState([]);
|
|
22
|
-
const [filter, setFilter] = useState("all");
|
|
23
|
-
const [search, setSearch] = useState("");
|
|
24
|
-
const [autoScroll, setAutoScroll] = useState(true);
|
|
25
|
-
const [connected, setConnected] = useState(false);
|
|
26
|
-
const bottomRef = useRef(null);
|
|
27
|
-
const containerRef = useRef(null);
|
|
28
|
-
const eventSourceRef = useRef(null);
|
|
29
|
-
|
|
30
|
-
// Load existing logs
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
api("/logs").then(setLogs).catch(() => {});
|
|
33
|
-
}, []);
|
|
34
|
-
|
|
35
|
-
// SSE stream for real-time logs
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
const passkey = getStoredPasskey();
|
|
38
|
-
const url = API + "/logs/stream" + (passkey ? "?passkey=" + encodeURIComponent(passkey) : "");
|
|
39
|
-
const es = new EventSource(url);
|
|
40
|
-
eventSourceRef.current = es;
|
|
41
|
-
|
|
42
|
-
es.onopen = () => setConnected(true);
|
|
43
|
-
es.onmessage = (e) => {
|
|
44
|
-
try {
|
|
45
|
-
const entry = JSON.parse(e.data);
|
|
46
|
-
setLogs((prev) => {
|
|
47
|
-
const next = [...prev, entry];
|
|
48
|
-
return next.length > 500 ? next.slice(-500) : next;
|
|
49
|
-
});
|
|
50
|
-
} catch {}
|
|
51
|
-
};
|
|
52
|
-
es.onerror = () => setConnected(false);
|
|
53
|
-
|
|
54
|
-
return () => es.close();
|
|
55
|
-
}, []);
|
|
56
|
-
|
|
57
|
-
// Auto-scroll to bottom
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
if (autoScroll && bottomRef.current) {
|
|
60
|
-
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
|
61
|
-
}
|
|
62
|
-
}, [logs, autoScroll]);
|
|
63
|
-
|
|
64
|
-
// Detect manual scroll
|
|
65
|
-
const handleScroll = useCallback(() => {
|
|
66
|
-
if (!containerRef.current) return;
|
|
67
|
-
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
|
68
|
-
setAutoScroll(scrollHeight - scrollTop - clientHeight < 60);
|
|
69
|
-
}, []);
|
|
70
|
-
|
|
71
|
-
const handleClear = async () => {
|
|
72
|
-
try {
|
|
73
|
-
await api("/logs", { method: "DELETE" });
|
|
74
|
-
setLogs([]);
|
|
75
|
-
toast("Logs cleared", "success");
|
|
76
|
-
} catch (err) {
|
|
77
|
-
toast(err.message, "error");
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const filtered = logs.filter((l) => {
|
|
82
|
-
if (filter !== "all" && l.type !== filter) return false;
|
|
83
|
-
if (search && !l.message.toLowerCase().includes(search.toLowerCase())) return false;
|
|
84
|
-
return true;
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const counts = {
|
|
88
|
-
all: logs.length,
|
|
89
|
-
log: logs.filter((l) => l.type === "log").length,
|
|
90
|
-
error: logs.filter((l) => l.type === "error").length,
|
|
91
|
-
warn: logs.filter((l) => l.type === "warn").length,
|
|
92
|
-
info: logs.filter((l) => l.type === "info").length,
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const formatTime = (ts) => {
|
|
96
|
-
try {
|
|
97
|
-
const d = new Date(ts);
|
|
98
|
-
return d.toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
99
|
-
+ "." + String(d.getMilliseconds()).padStart(3, "0");
|
|
100
|
-
} catch { return ""; }
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
return html`<div style=${{ display: "flex", flexDirection: "column", height: "100%", gap: "12px" }}>
|
|
104
|
-
|
|
105
|
-
<!-- Toolbar -->
|
|
106
|
-
<div style=${{ display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }}>
|
|
107
|
-
<div style=${{ display: "flex", gap: "4px", background: "var(--bg-surface)", borderRadius: "8px", padding: "3px" }}>
|
|
108
|
-
${["all", "log", "error", "warn", "info"].map(
|
|
109
|
-
(f) => html`<button key=${f} onClick=${() => setFilter(f)}
|
|
110
|
-
style=${{
|
|
111
|
-
padding: "4px 10px", fontSize: "11px", borderRadius: "6px", border: "none", cursor: "pointer",
|
|
112
|
-
background: filter === f ? "var(--accent)" : "transparent",
|
|
113
|
-
color: filter === f ? "#fff" : "var(--text-muted)",
|
|
114
|
-
fontWeight: filter === f ? 600 : 400,
|
|
115
|
-
}}>
|
|
116
|
-
${f.toUpperCase()}${counts[f] > 0 ? ` (${counts[f]})` : ""}
|
|
117
|
-
</button>`
|
|
118
|
-
)}
|
|
119
|
-
</div>
|
|
120
|
-
|
|
121
|
-
<input
|
|
122
|
-
type="text"
|
|
123
|
-
placeholder="Search logs..."
|
|
124
|
-
value=${search}
|
|
125
|
-
onInput=${(e) => setSearch(e.target.value)}
|
|
126
|
-
style=${{
|
|
127
|
-
flex: 1, minWidth: "160px", padding: "6px 10px", fontSize: "12px",
|
|
128
|
-
background: "var(--bg-surface)", border: "1px solid var(--border)", borderRadius: "6px",
|
|
129
|
-
color: "var(--text)", outline: "none",
|
|
130
|
-
}}
|
|
131
|
-
/>
|
|
132
|
-
|
|
133
|
-
<div style=${{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
134
|
-
<span style=${{
|
|
135
|
-
width: "8px", height: "8px", borderRadius: "50%",
|
|
136
|
-
background: connected ? "var(--success)" : "var(--error)",
|
|
137
|
-
display: "inline-block",
|
|
138
|
-
}} />
|
|
139
|
-
<span style=${{ fontSize: "11px", color: "var(--text-muted)" }}>
|
|
140
|
-
${connected ? "Live" : "Disconnected"}
|
|
141
|
-
</span>
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
<button onClick=${() => setAutoScroll(!autoScroll)}
|
|
145
|
-
style=${{
|
|
146
|
-
padding: "4px 10px", fontSize: "11px", borderRadius: "6px", border: "1px solid var(--border)",
|
|
147
|
-
cursor: "pointer",
|
|
148
|
-
background: autoScroll ? "var(--accent)" : "var(--bg-surface)",
|
|
149
|
-
color: autoScroll ? "#fff" : "var(--text-muted)",
|
|
150
|
-
}}>
|
|
151
|
-
Auto-scroll ${autoScroll ? "ON" : "OFF"}
|
|
152
|
-
</button>
|
|
153
|
-
|
|
154
|
-
<button onClick=${handleClear}
|
|
155
|
-
style=${{
|
|
156
|
-
padding: "4px 10px", fontSize: "11px", borderRadius: "6px",
|
|
157
|
-
border: "1px solid var(--border)", cursor: "pointer",
|
|
158
|
-
background: "var(--bg-surface)", color: "var(--error)",
|
|
159
|
-
}}>
|
|
160
|
-
Clear
|
|
161
|
-
</button>
|
|
162
|
-
</div>
|
|
163
|
-
|
|
164
|
-
<!-- Log Output -->
|
|
165
|
-
<div ref=${containerRef} onScroll=${handleScroll}
|
|
166
|
-
style=${{
|
|
167
|
-
flex: 1, overflow: "auto", background: "#0a0a0f", borderRadius: "8px",
|
|
168
|
-
border: "1px solid var(--border)", fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
|
169
|
-
fontSize: "12px", lineHeight: "1.7", padding: "12px 0",
|
|
170
|
-
}}>
|
|
171
|
-
${filtered.length === 0 && html`
|
|
172
|
-
<div style=${{ padding: "40px", textAlign: "center", color: "var(--text-muted)", fontSize: "12px" }}>
|
|
173
|
-
${logs.length === 0 ? "No logs yet. Console output from your app will appear here." : "No logs match the current filter."}
|
|
174
|
-
</div>
|
|
175
|
-
`}
|
|
176
|
-
${filtered.map(
|
|
177
|
-
(entry) => html`<div key=${entry.id}
|
|
178
|
-
style=${{
|
|
179
|
-
padding: "1px 14px", display: "flex", gap: "10px", alignItems: "flex-start",
|
|
180
|
-
borderLeft: entry.type === "error" ? "2px solid var(--error)" : entry.type === "warn" ? "2px solid var(--warning)" : "2px solid transparent",
|
|
181
|
-
background: entry.type === "error" ? "rgba(239,68,68,0.04)" : entry.type === "warn" ? "rgba(245,158,11,0.04)" : "transparent",
|
|
182
|
-
}}>
|
|
183
|
-
<span style=${{ color: "var(--text-muted)", fontSize: "10px", minWidth: "80px", paddingTop: "2px", userSelect: "none" }}>
|
|
184
|
-
${formatTime(entry.timestamp)}
|
|
185
|
-
</span>
|
|
186
|
-
<span style=${{
|
|
187
|
-
fontSize: "9px", fontWeight: 700, minWidth: "32px", textAlign: "center",
|
|
188
|
-
padding: "1px 4px", borderRadius: "3px", letterSpacing: "0.5px", paddingTop: "3px",
|
|
189
|
-
background: entry.type === "error" ? "rgba(239,68,68,0.15)" : entry.type === "warn" ? "rgba(245,158,11,0.15)" : entry.type === "info" ? "rgba(59,130,246,0.15)" : "rgba(255,255,255,0.06)",
|
|
190
|
-
color: TYPE_COLORS[entry.type] || "var(--text)",
|
|
191
|
-
}}>
|
|
192
|
-
${TYPE_BADGES[entry.type] || "LOG"}
|
|
193
|
-
</span>
|
|
194
|
-
<span style=${{
|
|
195
|
-
color: TYPE_COLORS[entry.type] || "var(--text)",
|
|
196
|
-
whiteSpace: "pre-wrap", wordBreak: "break-all", flex: 1,
|
|
197
|
-
}}>
|
|
198
|
-
${entry.message}
|
|
199
|
-
</span>
|
|
200
|
-
</div>`
|
|
201
|
-
)}
|
|
202
|
-
<div ref=${bottomRef} />
|
|
203
|
-
</div>
|
|
204
|
-
</div>`;
|
|
205
|
-
}
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback } from "react";
|
|
2
|
-
import { LuMail, LuCheck, LuSend } from "react-icons/lu";
|
|
3
|
-
import { html, api } from "../lib.js";
|
|
4
|
-
import { useApp } from "../context.js";
|
|
5
|
-
|
|
6
|
-
// Copy-paste usage shown in the right-hand code panel.
|
|
7
|
-
const USAGE_TABS = [
|
|
8
|
-
{
|
|
9
|
-
id: "basic",
|
|
10
|
-
label: "Basic",
|
|
11
|
-
code: `import { sendMail, emailTemplate } from '../services/mailer.js';
|
|
12
|
-
|
|
13
|
-
// Call from any route handler. Credentials come from .env.
|
|
14
|
-
await sendMail({
|
|
15
|
-
to: 'user@example.com',
|
|
16
|
-
subject: 'Welcome to 4Brains!',
|
|
17
|
-
html: emailTemplate({ title: 'Welcome', body: 'Thanks for signing up.' }),
|
|
18
|
-
// text: 'Plain-text fallback', // optional
|
|
19
|
-
// cc, bcc, replyTo, from // optional
|
|
20
|
-
});`,
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
id: "attachment",
|
|
24
|
-
label: "Attachment",
|
|
25
|
-
code: `import { sendMail } from '../services/mailer.js';
|
|
26
|
-
|
|
27
|
-
await sendMail({
|
|
28
|
-
to: 'user@example.com',
|
|
29
|
-
subject: 'Your invoice',
|
|
30
|
-
text: 'Invoice attached.',
|
|
31
|
-
attachments: [
|
|
32
|
-
// a) from a file on disk
|
|
33
|
-
{ filename: 'invoice.pdf', path: './invoices/inv-001.pdf' },
|
|
34
|
-
// b) generated content (string or Buffer)
|
|
35
|
-
{ filename: 'note.txt', content: 'Generated at ' + new Date().toISOString() },
|
|
36
|
-
// c) an uploaded file (multer) -> req.file
|
|
37
|
-
{ filename: req.file.originalname, path: req.file.path },
|
|
38
|
-
],
|
|
39
|
-
});`,
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
id: "ejs",
|
|
43
|
-
label: "Dynamic (EJS)",
|
|
44
|
-
code: `// Any template engine works: render it to an HTML string, pass as \`html\`.
|
|
45
|
-
import { sendMail } from '../services/mailer.js';
|
|
46
|
-
import ejs from 'ejs'; // npm i ejs
|
|
47
|
-
import path from 'path';
|
|
48
|
-
|
|
49
|
-
// views/emails/welcome.ejs:
|
|
50
|
-
// <h1>Hi <%= name %></h1>
|
|
51
|
-
// <p>Verify: <a href="<%= link %>"><%= link %></a></p>
|
|
52
|
-
|
|
53
|
-
const html = await ejs.renderFile(
|
|
54
|
-
path.join(process.cwd(), 'views/emails/welcome.ejs'),
|
|
55
|
-
{ name: user.name, link: 'https://app.com/verify/' + token } // dynamic data
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
await sendMail({ to: user.email, subject: 'Welcome', html });
|
|
59
|
-
|
|
60
|
-
// Handlebars / Pug / React Email: same idea — produce an HTML
|
|
61
|
-
// string from your data, then hand it to sendMail({ html }).`,
|
|
62
|
-
},
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
export function MailPage() {
|
|
66
|
-
const { toast, refresh } = useApp();
|
|
67
|
-
const [info, setInfo] = useState(null);
|
|
68
|
-
const [provider, setProvider] = useState("mailgun");
|
|
69
|
-
const [form, setForm] = useState({ host: "smtp.mailgun.org", port: 587, secure: false, user: "", pass: "", from: "" });
|
|
70
|
-
const [busy, setBusy] = useState(false);
|
|
71
|
-
const [testTo, setTestTo] = useState("");
|
|
72
|
-
const [testing, setTesting] = useState(false);
|
|
73
|
-
const [copied, setCopied] = useState(false);
|
|
74
|
-
const [tab, setTab] = useState("basic");
|
|
75
|
-
|
|
76
|
-
const activeTab = USAGE_TABS.find((t) => t.id === tab) || USAGE_TABS[0];
|
|
77
|
-
|
|
78
|
-
function copyUsage() {
|
|
79
|
-
try {
|
|
80
|
-
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(activeTab.code);
|
|
81
|
-
} catch (e) {}
|
|
82
|
-
setCopied(true);
|
|
83
|
-
setTimeout(() => setCopied(false), 1200);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const load = useCallback(async () => {
|
|
87
|
-
try {
|
|
88
|
-
const r = await api("/mail");
|
|
89
|
-
setInfo(r);
|
|
90
|
-
setForm((f) => ({
|
|
91
|
-
...f,
|
|
92
|
-
host: r.host || f.host,
|
|
93
|
-
port: r.port || f.port || 587,
|
|
94
|
-
secure: !!r.secure,
|
|
95
|
-
user: r.user || f.user,
|
|
96
|
-
from: r.from || f.from,
|
|
97
|
-
}));
|
|
98
|
-
} catch (err) { toast(err.message, "error"); }
|
|
99
|
-
}, [toast]);
|
|
100
|
-
|
|
101
|
-
useEffect(() => { load(); }, [load]);
|
|
102
|
-
|
|
103
|
-
function setField(patch) { setForm((f) => ({ ...f, ...patch })); }
|
|
104
|
-
|
|
105
|
-
function pickProvider(p) {
|
|
106
|
-
setProvider(p.id);
|
|
107
|
-
if (p.id !== "smtp") setField({ host: p.host, port: p.port, secure: p.secure, user: p.user || form.user });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function save() {
|
|
111
|
-
if (!form.host.trim()) { toast("SMTP host is required", "error"); return; }
|
|
112
|
-
setBusy(true);
|
|
113
|
-
try {
|
|
114
|
-
const r = await api("/mail/setup", { method: "POST", body: { provider, ...form } });
|
|
115
|
-
toast(r.message, "success");
|
|
116
|
-
await load();
|
|
117
|
-
refresh().catch(() => {});
|
|
118
|
-
} catch (err) { toast(err.message, "error"); }
|
|
119
|
-
setBusy(false);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function sendTest() {
|
|
123
|
-
if (!testTo.trim()) { toast("Enter a recipient", "error"); return; }
|
|
124
|
-
setTesting(true);
|
|
125
|
-
try {
|
|
126
|
-
const r = await api("/mail/test", { method: "POST", body: { to: testTo.trim() } });
|
|
127
|
-
toast(r.message, "success");
|
|
128
|
-
} catch (err) { toast(err.message, "error"); }
|
|
129
|
-
setTesting(false);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
|
|
133
|
-
|
|
134
|
-
const current = info.providers.find((p) => p.id === provider) || {};
|
|
135
|
-
// Render Mailgun first regardless of the backend list order.
|
|
136
|
-
const providers = info.providers.slice().sort((a, b) => (a.id === "mailgun" ? -1 : b.id === "mailgun" ? 1 : 0));
|
|
137
|
-
|
|
138
|
-
const labelStyle = { fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" };
|
|
139
|
-
|
|
140
|
-
return html`<div>
|
|
141
|
-
<div className="flex items-center justify-between mb-4">
|
|
142
|
-
<div>
|
|
143
|
-
<div className="card-title">Email</div>
|
|
144
|
-
<div className="card-subtitle">Transactional email via Mailgun, Brevo, SendGrid, Gmail, or any SMTP</div>
|
|
145
|
-
</div>
|
|
146
|
-
${info.configured && html`<span className="badge badge-success">Configured</span>`}
|
|
147
|
-
</div>
|
|
148
|
-
|
|
149
|
-
<div style=${{ display: "flex", gap: "16px", alignItems: "flex-start", flexWrap: "wrap" }}>
|
|
150
|
-
<!-- LEFT: provider + SMTP config -->
|
|
151
|
-
<div className="card" style=${{ flex: "1 1 420px", minWidth: 0 }}>
|
|
152
|
-
<div style=${labelStyle}>Provider</div>
|
|
153
|
-
<div style=${{ display: "flex", gap: "6px", flexWrap: "wrap", marginBottom: current.hint ? "6px" : "14px" }}>
|
|
154
|
-
${providers.map((p) => html`<button key=${p.id} className="badge"
|
|
155
|
-
onClick=${() => pickProvider(p)}
|
|
156
|
-
style=${{ cursor: "pointer", border: "1px solid var(--border)", background: provider === p.id ? "var(--accent-glow)" : "var(--bg-input)", color: provider === p.id ? "var(--accent-light)" : "var(--text-secondary)" }}>${p.label}</button>`)}
|
|
157
|
-
</div>
|
|
158
|
-
${current.hint && html`<div className="text-sm text-muted" style=${{ marginBottom: "14px" }}>${current.hint}</div>`}
|
|
159
|
-
|
|
160
|
-
<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
161
|
-
<div className="form-group" style=${{ flex: "1 1 200px" }}>
|
|
162
|
-
<label className="form-label">SMTP Host</label>
|
|
163
|
-
<input className="form-input" value=${form.host} onInput=${(e) => setField({ host: e.target.value })} placeholder="smtp.mailgun.org" />
|
|
164
|
-
</div>
|
|
165
|
-
<div className="form-group" style=${{ flex: "0 0 90px" }}>
|
|
166
|
-
<label className="form-label">Port</label>
|
|
167
|
-
<input className="form-input" type="number" value=${form.port} onInput=${(e) => setField({ port: e.target.value })} />
|
|
168
|
-
</div>
|
|
169
|
-
<div className="form-group" style=${{ flex: "0 0 120px", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
|
|
170
|
-
<label style=${{ display: "flex", alignItems: "center", gap: "6px", fontSize: "13px", color: "var(--text-secondary)", cursor: "pointer", paddingBottom: "10px" }}>
|
|
171
|
-
<input type="checkbox" checked=${form.secure} onChange=${(e) => setField({ secure: e.target.checked })} /> Secure (465)
|
|
172
|
-
</label>
|
|
173
|
-
</div>
|
|
174
|
-
</div>
|
|
175
|
-
<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
176
|
-
<div className="form-group" style=${{ flex: "1 1 180px" }}>
|
|
177
|
-
<label className="form-label">User</label>
|
|
178
|
-
<input className="form-input" value=${form.user} onInput=${(e) => setField({ user: e.target.value })} placeholder="SMTP username" />
|
|
179
|
-
</div>
|
|
180
|
-
<div className="form-group" style=${{ flex: "1 1 180px" }}>
|
|
181
|
-
<label className="form-label">Password ${info.hasPass ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved)</span>` : ""}</label>
|
|
182
|
-
<input className="form-input" type="password" value=${form.pass} onInput=${(e) => setField({ pass: e.target.value })} placeholder=${info.hasPass ? "••••••••" : "secret"} />
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
185
|
-
<div className="form-group">
|
|
186
|
-
<label className="form-label">From address</label>
|
|
187
|
-
<input className="form-input" value=${form.from} onInput=${(e) => setField({ from: e.target.value })} placeholder="no-reply@yourdomain.com" />
|
|
188
|
-
</div>
|
|
189
|
-
<button className="btn btn-primary" onClick=${save} disabled=${busy || !form.host.trim()}>
|
|
190
|
-
${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
|
|
191
|
-
${busy ? "Saving..." : "Save & install"}
|
|
192
|
-
</button>
|
|
193
|
-
|
|
194
|
-
${info.configured && html`<div style=${{ marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border)" }}>
|
|
195
|
-
<div style=${labelStyle}>Send test email</div>
|
|
196
|
-
<div style=${{ display: "flex", gap: "8px", alignItems: "flex-end", flexWrap: "wrap" }}>
|
|
197
|
-
<div className="form-group" style=${{ flex: "1 1 200px", marginBottom: 0 }}>
|
|
198
|
-
<input className="form-input" value=${testTo} onInput=${(e) => setTestTo(e.target.value)} placeholder="you@example.com" />
|
|
199
|
-
</div>
|
|
200
|
-
<button className="btn btn-primary" onClick=${sendTest} disabled=${testing || !testTo.trim()}>
|
|
201
|
-
${testing ? html`<span className="spinner" />` : html`<${LuSend} size=${16} />`}
|
|
202
|
-
${testing ? "Sending..." : "Send test"}
|
|
203
|
-
</button>
|
|
204
|
-
</div>
|
|
205
|
-
</div>`}
|
|
206
|
-
</div>
|
|
207
|
-
|
|
208
|
-
<!-- RIGHT: code only -->
|
|
209
|
-
<div className="card" style=${{ flex: "1 1 360px", minWidth: 0 }}>
|
|
210
|
-
<div className="flex items-center justify-between mb-2">
|
|
211
|
-
<div style=${{ display: "flex", gap: "4px", flexWrap: "wrap" }}>
|
|
212
|
-
${USAGE_TABS.map((t) => html`<button key=${t.id}
|
|
213
|
-
onClick=${() => setTab(t.id)}
|
|
214
|
-
style=${{ cursor: "pointer", fontSize: "12px", padding: "5px 11px", borderRadius: "7px", border: "1px solid var(--border)", background: tab === t.id ? "var(--accent-glow)" : "var(--bg-input)", color: tab === t.id ? "var(--accent-light)" : "var(--text-secondary)" }}>${t.label}</button>`)}
|
|
215
|
-
</div>
|
|
216
|
-
<button className="btn btn-sm" style=${{ background: "var(--bg-input)", border: "1px solid var(--border)", color: "var(--text-secondary)" }} onClick=${copyUsage}>
|
|
217
|
-
${copied ? html`<${LuCheck} size=${14} />` : null} ${copied ? "Copied" : "Copy"}
|
|
218
|
-
</button>
|
|
219
|
-
</div>
|
|
220
|
-
<pre style=${{ background: "#0a0c12", border: "1px solid var(--border)", borderRadius: "8px", padding: "14px", fontSize: "12px", lineHeight: 1.55, color: "#d5d8e3", overflow: "auto", margin: 0, fontFamily: "ui-monospace,Menlo,monospace", whiteSpace: "pre" }}>${activeTab.code}</pre>
|
|
221
|
-
</div>
|
|
222
|
-
</div>
|
|
223
|
-
</div>`;
|
|
224
|
-
}
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
2
|
-
import { motion, AnimatePresence } from "framer-motion";
|
|
3
|
-
import { LuActivity, LuCheck, LuRadio, LuZap } from "react-icons/lu";
|
|
4
|
-
import { html, api } from "../lib.js";
|
|
5
|
-
import { useApp } from "../context.js";
|
|
6
|
-
|
|
7
|
-
export function RealtimePage() {
|
|
8
|
-
const { toast, refresh, status, showConfirm } = useApp();
|
|
9
|
-
const [selected, setSelected] = useState(status?.socketConfigured ? 'socketio' : status?.websocketConfigured ? 'websocket' : 'socketio');
|
|
10
|
-
|
|
11
|
-
const items = [
|
|
12
|
-
{ id: 'socketio', label: 'Socket.IO', desc: 'Auto-reconnect, rooms, namespaces', configured: status?.socketConfigured },
|
|
13
|
-
{ id: 'websocket', label: 'WebSocket', desc: 'Native ws library, lightweight', configured: status?.websocketConfigured },
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
return html`<div>
|
|
17
|
-
<div className="flex items-center justify-between mb-4">
|
|
18
|
-
<div>
|
|
19
|
-
<div className="card-title">Real-time</div>
|
|
20
|
-
<div className="card-subtitle">Configure real-time communication</div>
|
|
21
|
-
</div>
|
|
22
|
-
</div>
|
|
23
|
-
|
|
24
|
-
<div className="routes-layout">
|
|
25
|
-
<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
|
|
26
|
-
<div style=${{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border)' }}>
|
|
27
|
-
<span style=${{ fontWeight: 600, fontSize: '12px', textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Protocols</span>
|
|
28
|
-
</div>
|
|
29
|
-
<div className="routes-list">
|
|
30
|
-
${items.map(item => html`<div key=${item.id}
|
|
31
|
-
className=${`route-item ${selected === item.id ? 'active' : ''}`}
|
|
32
|
-
onClick=${() => setSelected(item.id)}>
|
|
33
|
-
<div style=${{ display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 }}>
|
|
34
|
-
<span className="ri-name">${item.label}</span>
|
|
35
|
-
<span className="ri-file">${item.desc}</span>
|
|
36
|
-
</div>
|
|
37
|
-
${item.configured && html`<span className="badge badge-success" style=${{ fontSize: '10px' }}>Active</span>`}
|
|
38
|
-
</div>`)}
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
|
-
<div className="routes-detail">
|
|
43
|
-
<${AnimatePresence} mode="wait">
|
|
44
|
-
<${motion.div} key=${selected}
|
|
45
|
-
initial=${{ opacity: 0, y: 6 }} animate=${{ opacity: 1, y: 0 }} exit=${{ opacity: 0, y: -6 }}
|
|
46
|
-
transition=${{ duration: 0.12 }}>
|
|
47
|
-
${selected === 'socketio' ? html`<${SocketIOSetup} />` : html`<${WebSocketSetup} />`}
|
|
48
|
-
<//>
|
|
49
|
-
<//>
|
|
50
|
-
</div>
|
|
51
|
-
</div>
|
|
52
|
-
</div>`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function SocketIOSetup() {
|
|
56
|
-
const { toast, refresh, status, showConfirm } = useApp();
|
|
57
|
-
const [cors, setCors] = useState("*");
|
|
58
|
-
const [loading, setLoading] = useState(false);
|
|
59
|
-
|
|
60
|
-
if (status?.socketConfigured) {
|
|
61
|
-
return html`<div>
|
|
62
|
-
<div className="card mb-4" style=${{ borderColor: 'rgba(34,197,94,0.3)' }}>
|
|
63
|
-
<div className="flex items-center gap-3">
|
|
64
|
-
<${LuActivity} size=${20} color="var(--success)" />
|
|
65
|
-
<div style=${{ flex: 1 }}>
|
|
66
|
-
<div style=${{ fontWeight: 600 }}>Socket.IO Active</div>
|
|
67
|
-
<div className="text-sm text-muted">Real-time events are configured in index.js</div>
|
|
68
|
-
</div>
|
|
69
|
-
<span className="badge badge-success">Configured</span>
|
|
70
|
-
</div>
|
|
71
|
-
</div>
|
|
72
|
-
<div className="card">
|
|
73
|
-
<div style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: '8px' }}>Features</div>
|
|
74
|
-
${['Auto-reconnect on disconnect', 'Room & namespace support', 'Binary data streaming', 'Middleware pipeline'].map(f =>
|
|
75
|
-
html`<div key=${f} style=${{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
76
|
-
<${LuCheck} size=${14} color="var(--success)" /> ${f}
|
|
77
|
-
</div>`
|
|
78
|
-
)}
|
|
79
|
-
</div>
|
|
80
|
-
</div>`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function handleSetup() {
|
|
84
|
-
if (status?.websocketConfigured) {
|
|
85
|
-
const ok = await showConfirm('WebSocket is currently configured. Enabling Socket.IO will replace your WebSocket setup. Do you want to continue?', { confirmLabel: 'Replace with Socket.IO' });
|
|
86
|
-
if (!ok) return;
|
|
87
|
-
}
|
|
88
|
-
setLoading(true);
|
|
89
|
-
try {
|
|
90
|
-
const result = await api('/add-socket', { method: 'POST', body: { corsOrigins: cors } });
|
|
91
|
-
toast(result.message, 'success');
|
|
92
|
-
refresh().catch(() => {});
|
|
93
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
94
|
-
setLoading(false);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return html`<div className="card">
|
|
98
|
-
<div className="card-title mb-2">Socket.IO Setup</div>
|
|
99
|
-
<div className="card-subtitle mb-4">Add real-time bidirectional communication with auto-reconnect, rooms, and namespaces</div>
|
|
100
|
-
${status?.websocketConfigured && html`<div style=${{ padding: '10px 14px', borderRadius: '8px', background: 'var(--warning-glow)', border: '1px solid rgba(245,158,11,0.2)', marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
101
|
-
<${LuZap} size=${14} color="var(--warning)" />
|
|
102
|
-
<span style=${{ fontSize: '12px', color: 'var(--warning)' }}>WebSocket is currently active. Setting up Socket.IO will replace it.</span>
|
|
103
|
-
</div>`}
|
|
104
|
-
<div className="form-group">
|
|
105
|
-
<label className="form-label">CORS Origins</label>
|
|
106
|
-
<input className="form-input" value=${cors} onInput=${e => setCors(e.target.value)} placeholder="* or comma-separated origins" />
|
|
107
|
-
</div>
|
|
108
|
-
<button className="btn btn-primary" onClick=${handleSetup} disabled=${loading}>
|
|
109
|
-
${loading ? html`<span className="spinner" />` : null}
|
|
110
|
-
${loading ? 'Setting up...' : 'Setup Socket.IO'}
|
|
111
|
-
</button>
|
|
112
|
-
</div>`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function WebSocketSetup() {
|
|
116
|
-
const { toast, refresh, status, showConfirm } = useApp();
|
|
117
|
-
const [samePort, setSamePort] = useState(true);
|
|
118
|
-
const [wsPort, setWsPort] = useState("8080");
|
|
119
|
-
const [loading, setLoading] = useState(false);
|
|
120
|
-
|
|
121
|
-
if (status?.websocketConfigured) {
|
|
122
|
-
return html`<div>
|
|
123
|
-
<div className="card mb-4" style=${{ borderColor: 'rgba(34,197,94,0.3)' }}>
|
|
124
|
-
<div className="flex items-center gap-3">
|
|
125
|
-
<${LuRadio} size=${20} color="var(--success)" />
|
|
126
|
-
<div style=${{ flex: 1 }}>
|
|
127
|
-
<div style=${{ fontWeight: 600 }}>WebSocket Active</div>
|
|
128
|
-
<div className="text-sm text-muted">Native WebSocket is configured in index.js</div>
|
|
129
|
-
</div>
|
|
130
|
-
<span className="badge badge-success">Configured</span>
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
<div className="card">
|
|
134
|
-
<div style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: '8px' }}>Features</div>
|
|
135
|
-
${['Lightweight native protocol', 'Low latency messaging', 'Binary frame support', 'Custom message handling'].map(f =>
|
|
136
|
-
html`<div key=${f} style=${{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
137
|
-
<${LuCheck} size=${14} color="var(--success)" /> ${f}
|
|
138
|
-
</div>`
|
|
139
|
-
)}
|
|
140
|
-
</div>
|
|
141
|
-
</div>`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function handleSetup() {
|
|
145
|
-
if (status?.socketConfigured) {
|
|
146
|
-
const ok = await showConfirm('Socket.IO is currently configured. Enabling WebSocket will replace your Socket.IO setup. Do you want to continue?', { confirmLabel: 'Replace with WebSocket' });
|
|
147
|
-
if (!ok) return;
|
|
148
|
-
}
|
|
149
|
-
setLoading(true);
|
|
150
|
-
try {
|
|
151
|
-
const result = await api('/add-websocket', { method: 'POST', body: { samePort, wsPort: samePort ? null : parseInt(wsPort) } });
|
|
152
|
-
toast(result.message, 'success');
|
|
153
|
-
refresh().catch(() => {});
|
|
154
|
-
} catch (err) { toast(err.message, 'error'); }
|
|
155
|
-
setLoading(false);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return html`<div className="card">
|
|
159
|
-
<div className="card-title mb-2">Native WebSocket Setup</div>
|
|
160
|
-
<div className="card-subtitle mb-4">Add lightweight WebSocket support using the ws library for low-level control</div>
|
|
161
|
-
${status?.socketConfigured && html`<div style=${{ padding: '10px 14px', borderRadius: '8px', background: 'var(--warning-glow)', border: '1px solid rgba(245,158,11,0.2)', marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
162
|
-
<${LuZap} size=${14} color="var(--warning)" />
|
|
163
|
-
<span style=${{ fontSize: '12px', color: 'var(--warning)' }}>Socket.IO is currently active. Setting up WebSocket will replace it.</span>
|
|
164
|
-
</div>`}
|
|
165
|
-
<div className="form-group">
|
|
166
|
-
<label className="form-label">Server Port</label>
|
|
167
|
-
<div style=${{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
|
168
|
-
<label style=${{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
169
|
-
<input type="radio" name="wsPort" checked=${samePort} onChange=${() => setSamePort(true)} /> Same as HTTP server
|
|
170
|
-
</label>
|
|
171
|
-
<label style=${{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
|
172
|
-
<input type="radio" name="wsPort" checked=${!samePort} onChange=${() => setSamePort(false)} /> Separate port
|
|
173
|
-
</label>
|
|
174
|
-
</div>
|
|
175
|
-
</div>
|
|
176
|
-
${!samePort && html`<div className="form-group">
|
|
177
|
-
<label className="form-label">WebSocket Port</label>
|
|
178
|
-
<input className="form-input" type="number" value=${wsPort} onInput=${e => setWsPort(e.target.value)} placeholder="8080" style=${{ maxWidth: '160px' }} />
|
|
179
|
-
</div>`}
|
|
180
|
-
<button className="btn btn-primary" onClick=${handleSetup} disabled=${loading}>
|
|
181
|
-
${loading ? html`<span className="spinner" />` : null}
|
|
182
|
-
${loading ? 'Setting up...' : 'Setup WebSocket'}
|
|
183
|
-
</button>
|
|
184
|
-
</div>`;
|
|
185
|
-
}
|
|
186
|
-
|