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.
- package/README.md +10 -0
- package/add-login.js +2 -2
- package/add-serialport.js +1 -1
- package/index.js +60 -6
- package/lib/indexFile.js +60 -13
- package/lib/ui.js +1 -0
- 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
package/README.md
CHANGED
|
@@ -92,6 +92,16 @@ Presets available: `user`, `product`, `post`
|
|
|
92
92
|
4bnode list routes # List routes only
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
### Dashboard Passkey
|
|
96
|
+
|
|
97
|
+
During `npx 4bnode init`, you'll be prompted to set a 6-digit passkey to protect the dev dashboard.
|
|
98
|
+
|
|
99
|
+
To change the passkey later, run inside your project:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
4bnode passkey
|
|
103
|
+
```
|
|
104
|
+
|
|
95
105
|
### Start Dev Dashboard
|
|
96
106
|
|
|
97
107
|
```sh
|
package/add-login.js
CHANGED
|
@@ -168,13 +168,13 @@ router.post('/login', async (req, res) => {
|
|
|
168
168
|
try {
|
|
169
169
|
const user = await ${pascalName}.findOne({ ${selectedFields[0]}: ${selectedFields[0]} });
|
|
170
170
|
if (!user) {
|
|
171
|
-
return res.status(
|
|
171
|
+
return res.status(401).json({ message: 'Invalid credentials' });
|
|
172
172
|
}
|
|
173
173
|
${
|
|
174
174
|
selectedFields.includes("password")
|
|
175
175
|
? `const isMatch = await bcrypt.compare(password, user.password);
|
|
176
176
|
if (!isMatch) {
|
|
177
|
-
return res.status(
|
|
177
|
+
return res.status(401).json({ message: 'Invalid credentials' });
|
|
178
178
|
}`
|
|
179
179
|
: ""
|
|
180
180
|
}
|
package/add-serialport.js
CHANGED
package/index.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
+
import crypto from "crypto";
|
|
5
6
|
import { execFileSync } from "child_process";
|
|
6
7
|
import { fileURLToPath } from "url";
|
|
7
8
|
import chalk from "chalk";
|
|
8
|
-
import { input, select } from "@inquirer/prompts";
|
|
9
|
+
import { input, select, password } from "@inquirer/prompts";
|
|
9
10
|
import { sanitizeName } from "./lib/names.js";
|
|
10
11
|
import {
|
|
11
12
|
checkMongoConfig,
|
|
@@ -53,7 +54,38 @@ function updatePackageName(destinationPath, projectName) {
|
|
|
53
54
|
);
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function
|
|
57
|
+
function hashPasskey(pin) {
|
|
58
|
+
return crypto.createHash("sha256").update(pin).digest("hex");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function promptPasskey() {
|
|
62
|
+
const passkey = await password({
|
|
63
|
+
message: "Set a 6-digit passkey for the dev dashboard:",
|
|
64
|
+
mask: "*",
|
|
65
|
+
validate: (v) => {
|
|
66
|
+
if (!/^\d{6}$/.test(v)) return "Passkey must be exactly 6 digits";
|
|
67
|
+
return true;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const confirm = await password({
|
|
72
|
+
message: "Confirm passkey:",
|
|
73
|
+
mask: "*",
|
|
74
|
+
validate: (v) => {
|
|
75
|
+
if (v !== passkey) return "Passkeys do not match";
|
|
76
|
+
return true;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return passkey;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function savePasskey(projectPath, passkey) {
|
|
84
|
+
const hash = hashPasskey(passkey);
|
|
85
|
+
fs.writeFileSync(path.join(projectPath, ".4bnode", ".passkey"), hash, "utf8");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function createSkeletonApp(projectName) {
|
|
57
89
|
showBanner();
|
|
58
90
|
|
|
59
91
|
const targetPath = path.resolve(projectName);
|
|
@@ -67,9 +99,15 @@ function createSkeletonApp(projectName) {
|
|
|
67
99
|
updatePackageName(targetPath, projectName);
|
|
68
100
|
scaffoldSpinner.succeed(chalk.hex("#00f260").bold("Project scaffolded"));
|
|
69
101
|
|
|
102
|
+
// Ask for dashboard passkey
|
|
103
|
+
console.log();
|
|
104
|
+
const passkey = await promptPasskey();
|
|
105
|
+
savePasskey(targetPath, passkey);
|
|
106
|
+
console.log(chalk.hex("#00f260").bold(" ✔ Dashboard passkey set"));
|
|
107
|
+
|
|
70
108
|
const installSpinner = createSpinner("Installing dependencies...");
|
|
71
109
|
installSpinner.start();
|
|
72
|
-
execFileSync("npm", ["install"], { stdio: "pipe", cwd: targetPath });
|
|
110
|
+
execFileSync("npm", ["install"], { stdio: "pipe", cwd: targetPath, shell: true });
|
|
73
111
|
installSpinner.succeed(chalk.hex("#00f260").bold("Dependencies installed"));
|
|
74
112
|
|
|
75
113
|
showSuccess([
|
|
@@ -80,13 +118,14 @@ function createSkeletonApp(projectName) {
|
|
|
80
118
|
showNextSteps([
|
|
81
119
|
`cd ${projectName}`,
|
|
82
120
|
"npm run dev",
|
|
83
|
-
"Open http://localhost:3000",
|
|
121
|
+
"Open http://localhost:3000/_dev",
|
|
84
122
|
]);
|
|
85
123
|
}
|
|
86
124
|
|
|
87
125
|
function runCommand(script, args = []) {
|
|
88
126
|
execFileSync("node", [path.join(__dirname, script), ...args], {
|
|
89
127
|
stdio: "inherit",
|
|
128
|
+
shell: true,
|
|
90
129
|
});
|
|
91
130
|
}
|
|
92
131
|
|
|
@@ -210,6 +249,7 @@ function showHelp() {
|
|
|
210
249
|
console.log(` ${c("schema")} ${d("[name]")} Manage schemas (create/view/edit)`);
|
|
211
250
|
console.log(` ${c("list")} ${d("[filter]")} List models, routes & config status`);
|
|
212
251
|
console.log(` ${c("ui")} Start dev server with visual dashboard`);
|
|
252
|
+
console.log(` ${c("passkey")} Change dashboard passkey`);
|
|
213
253
|
console.log();
|
|
214
254
|
console.log(h(" ADD FEATURES\n"));
|
|
215
255
|
console.log(` ${c("add api")} ${d("[name]")} API endpoint`);
|
|
@@ -306,11 +346,24 @@ async function main() {
|
|
|
306
346
|
}
|
|
307
347
|
} else if (command === "list") {
|
|
308
348
|
showList(subCommand);
|
|
349
|
+
} else if (command === "passkey") {
|
|
350
|
+
// Must be inside a 4bnode project
|
|
351
|
+
const projectRoot = process.cwd();
|
|
352
|
+
const passkeyFile = path.join(projectRoot, ".4bnode", ".passkey");
|
|
353
|
+
if (!fs.existsSync(path.join(projectRoot, ".4bnode"))) {
|
|
354
|
+
showError("Not inside a 4bnode project. Run 'npx 4bnode init <name>' first.");
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
showBanner();
|
|
358
|
+
console.log();
|
|
359
|
+
const newPasskey = await promptPasskey();
|
|
360
|
+
savePasskey(projectRoot, newPasskey);
|
|
361
|
+
console.log(chalk.hex("#00f260").bold("\n ✔ Dashboard passkey updated successfully\n"));
|
|
309
362
|
} else if (command === "ui") {
|
|
310
|
-
// Must be inside a 4bnode project (has package.json +
|
|
363
|
+
// Must be inside a 4bnode project (has package.json + .4bnode/)
|
|
311
364
|
const projectRoot = process.cwd();
|
|
312
365
|
const hasPackageJson = fs.existsSync(path.join(projectRoot, "package.json"));
|
|
313
|
-
const hasDevApi = fs.existsSync(path.join(projectRoot, "dev-api.js"));
|
|
366
|
+
const hasDevApi = fs.existsSync(path.join(projectRoot, ".4bnode", "dev-api.js")) || fs.existsSync(path.join(projectRoot, "dev-api.js"));
|
|
314
367
|
if (!hasPackageJson || !hasDevApi) {
|
|
315
368
|
showError("Not inside a 4bnode project. Run 'npx 4bnode init <name>' first, then cd into the project folder.");
|
|
316
369
|
process.exit(1);
|
|
@@ -319,6 +372,7 @@ async function main() {
|
|
|
319
372
|
execFileSync("npm", ["run", "dev"], {
|
|
320
373
|
stdio: "inherit",
|
|
321
374
|
cwd: projectRoot,
|
|
375
|
+
shell: true,
|
|
322
376
|
});
|
|
323
377
|
}
|
|
324
378
|
// Legacy commands → route through handleAdd
|
package/lib/indexFile.js
CHANGED
|
@@ -40,6 +40,11 @@ export function addRouteRegistration(importLine, useLine) {
|
|
|
40
40
|
"server.listen(port",
|
|
41
41
|
useLine.trim() + "\n" + "server.listen(port"
|
|
42
42
|
);
|
|
43
|
+
} else if (content.match(/^startServer\(/m)) {
|
|
44
|
+
content = content.replace(
|
|
45
|
+
/^startServer\(/m,
|
|
46
|
+
useLine.trim() + "\n" + "startServer("
|
|
47
|
+
);
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -48,7 +53,7 @@ export function addRouteRegistration(importLine, useLine) {
|
|
|
48
53
|
|
|
49
54
|
export function insertBeforeListen(codeBlock) {
|
|
50
55
|
let content = readIndex();
|
|
51
|
-
const listenMatch = content.match(/(app|server)\.listen\(port/);
|
|
56
|
+
const listenMatch = content.match(/(app|server)\.listen\(port/) || content.match(/^startServer\(/m);
|
|
52
57
|
if (listenMatch) {
|
|
53
58
|
content = content.replace(
|
|
54
59
|
listenMatch[0],
|
|
@@ -62,25 +67,67 @@ export function insertBeforeListen(codeBlock) {
|
|
|
62
67
|
|
|
63
68
|
export function replaceAppListen(newListenBlock) {
|
|
64
69
|
let content = readIndex();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
|
|
71
|
+
// If using startServer() pattern, just swap app.listen → server.listen inside it
|
|
72
|
+
if (content.includes("startServer(")) {
|
|
73
|
+
content = content.replace(
|
|
74
|
+
/const listener = app\.listen\(tryPort\)/,
|
|
75
|
+
"const listener = server.listen(tryPort)"
|
|
76
|
+
);
|
|
77
|
+
// Also swap standalone app.listen(tryPort) if no 'const listener' pattern
|
|
78
|
+
if (!content.includes("server.listen(tryPort)")) {
|
|
79
|
+
content = content.replace(
|
|
80
|
+
/app\.listen\(tryPort\)/,
|
|
81
|
+
"server.listen(tryPort)"
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
content = content.replace(/app\.listen\(port[\s\S]*?\}\);?\n?/m, "");
|
|
86
|
+
if (!content.includes("server.listen(port")) {
|
|
87
|
+
content += "\n" + newListenBlock.trim() + "\n";
|
|
88
|
+
}
|
|
68
89
|
}
|
|
69
90
|
writeIndex(content);
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
export function insertBeforeRoutes(codeBlock) {
|
|
73
94
|
let content = readIndex();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
|
|
96
|
+
// Insert before startServer() or the start server section
|
|
97
|
+
const markers = [
|
|
98
|
+
"// ── Start Server",
|
|
99
|
+
"// ── Helpers",
|
|
100
|
+
"startServer(",
|
|
101
|
+
];
|
|
102
|
+
let inserted = false;
|
|
103
|
+
for (const marker of markers) {
|
|
104
|
+
const idx = content.indexOf(marker);
|
|
105
|
+
if (idx !== -1) {
|
|
106
|
+
content =
|
|
107
|
+
content.slice(0, idx) +
|
|
108
|
+
codeBlock.trim() +
|
|
109
|
+
"\n\n" +
|
|
110
|
+
content.slice(idx);
|
|
111
|
+
inserted = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
83
114
|
}
|
|
115
|
+
|
|
116
|
+
if (!inserted) {
|
|
117
|
+
// Fallback: before first app.use that isn't middleware
|
|
118
|
+
const routeUseMatch = content.match(/app\.use\(['"]\/(?!_dev)/);
|
|
119
|
+
if (routeUseMatch) {
|
|
120
|
+
const idx = content.indexOf(routeUseMatch[0]);
|
|
121
|
+
content =
|
|
122
|
+
content.slice(0, idx) +
|
|
123
|
+
codeBlock.trim() +
|
|
124
|
+
"\n\n" +
|
|
125
|
+
content.slice(idx);
|
|
126
|
+
} else {
|
|
127
|
+
content += "\n" + codeBlock.trim() + "\n";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
84
131
|
writeIndex(content);
|
|
85
132
|
}
|
|
86
133
|
|
package/lib/ui.js
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
3
|
+
import { html, api, PAGE_TITLES } from "./lib.js";
|
|
4
|
+
import { AppContext } from "./context.js";
|
|
5
|
+
import { Sidebar } from "./components/Sidebar.js";
|
|
6
|
+
import { ToastContainer } from "./components/ToastContainer.js";
|
|
7
|
+
import { ConfirmDialog } from "./components/Modal.js";
|
|
8
|
+
import { DashboardPage } from "./pages/DashboardPage.js";
|
|
9
|
+
import { DatabasePage } from "./pages/DatabasePage.js";
|
|
10
|
+
import { SchemasPage } from "./pages/SchemasPage.js";
|
|
11
|
+
import { RoutesPage } from "./pages/RoutesPage.js";
|
|
12
|
+
import { ApiTesterPage } from "./pages/ApiTesterPage.js";
|
|
13
|
+
import { RealtimePage } from "./pages/RealtimePage.js";
|
|
14
|
+
import { SerialPortPage } from "./pages/SerialPortPage.js";
|
|
15
|
+
import { EnvironmentPage } from "./pages/EnvironmentPage.js";
|
|
16
|
+
|
|
17
|
+
function getHashPage() {
|
|
18
|
+
const hash = window.location.hash.replace("#/", "").replace("#", "");
|
|
19
|
+
return hash && PAGE_TITLES[hash] ? hash : "dashboard";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function App() {
|
|
23
|
+
const [page, setPageState] = useState(getHashPage);
|
|
24
|
+
const [data, setData] = useState({
|
|
25
|
+
models: [],
|
|
26
|
+
routes: [],
|
|
27
|
+
mongoConfigured: false,
|
|
28
|
+
routesDirExists: false,
|
|
29
|
+
});
|
|
30
|
+
const [status, setStatus] = useState({});
|
|
31
|
+
const [toasts, setToasts] = useState([]);
|
|
32
|
+
const [confirmState, setConfirmState] = useState(null);
|
|
33
|
+
|
|
34
|
+
const showConfirm = useCallback((message, opts = {}) => {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
setConfirmState({ message, ...opts, resolve });
|
|
37
|
+
});
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const setPage = useCallback((p) => {
|
|
41
|
+
window.location.hash = "#/" + p;
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
function onHash() {
|
|
46
|
+
setPageState(getHashPage());
|
|
47
|
+
}
|
|
48
|
+
window.addEventListener("hashchange", onHash);
|
|
49
|
+
return () => window.removeEventListener("hashchange", onHash);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const toast = useCallback((message, type = "info") => {
|
|
53
|
+
const id = Date.now();
|
|
54
|
+
setToasts((prev) => [...prev, { id, message, type }]);
|
|
55
|
+
setTimeout(
|
|
56
|
+
() => setToasts((prev) => prev.filter((t) => t.id !== id)),
|
|
57
|
+
3500,
|
|
58
|
+
);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const refresh = useCallback(async () => {
|
|
62
|
+
const [dashData, statusData] = await Promise.all([
|
|
63
|
+
api("/dashboard"),
|
|
64
|
+
api("/status"),
|
|
65
|
+
]);
|
|
66
|
+
setData(dashData);
|
|
67
|
+
setStatus(statusData);
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
refresh().catch(() => {});
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const pages = {
|
|
75
|
+
dashboard: html`<${DashboardPage} />`,
|
|
76
|
+
database: html`<${DatabasePage} />`,
|
|
77
|
+
schemas: html`<${SchemasPage} />`,
|
|
78
|
+
routes: html`<${RoutesPage} />`,
|
|
79
|
+
"api-tester": html`<${ApiTesterPage} />`,
|
|
80
|
+
realtime: html`<${RealtimePage} />`,
|
|
81
|
+
serialport: html`<${SerialPortPage} />`,
|
|
82
|
+
env: html`<${EnvironmentPage} />`,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return html`<${AppContext.Provider}
|
|
86
|
+
value=${{ page, setPage, data, status, toasts, toast, refresh, showConfirm }}
|
|
87
|
+
>
|
|
88
|
+
<${Sidebar} />
|
|
89
|
+
<div className="main">
|
|
90
|
+
<div className="topbar">
|
|
91
|
+
<h2>${PAGE_TITLES[page] || "Dashboard"}</h2>
|
|
92
|
+
<span className="topbar-badge">Development</span>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="content">
|
|
95
|
+
<${AnimatePresence} mode="wait">
|
|
96
|
+
<${motion.div} key=${page}
|
|
97
|
+
initial=${{ opacity: 0, y: 8 }}
|
|
98
|
+
animate=${{ opacity: 1, y: 0 }}
|
|
99
|
+
exit=${{ opacity: 0, y: -8 }}
|
|
100
|
+
transition=${{ duration: 0.15 }}>
|
|
101
|
+
${pages[page] || pages.dashboard}
|
|
102
|
+
<//>
|
|
103
|
+
<//>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
<${ToastContainer} />
|
|
107
|
+
${confirmState && html`<${ConfirmDialog}
|
|
108
|
+
message=${confirmState.message}
|
|
109
|
+
confirmLabel=${confirmState.confirmLabel}
|
|
110
|
+
danger=${confirmState.danger}
|
|
111
|
+
onConfirm=${() => { confirmState.resolve(true); setConfirmState(null); }}
|
|
112
|
+
onCancel=${() => { confirmState.resolve(false); setConfirmState(null); }}
|
|
113
|
+
/>`}
|
|
114
|
+
<//>`;
|
|
115
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { AnimatePresence } from "framer-motion";
|
|
3
|
+
import { motion } from "framer-motion";
|
|
4
|
+
import { html, API, getStoredPasskey } from "./lib.js";
|
|
5
|
+
import { App } from "./App.js";
|
|
6
|
+
import { SplashScreen } from "./screens/SplashScreen.js";
|
|
7
|
+
import { PasskeyScreen } from "./screens/PasskeyScreen.js";
|
|
8
|
+
|
|
9
|
+
export function Root() {
|
|
10
|
+
const [showSplash, setShowSplash] = useState(true);
|
|
11
|
+
const [authed, setAuthed] = useState(false);
|
|
12
|
+
const [checking, setChecking] = useState(true);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
(async () => {
|
|
16
|
+
try {
|
|
17
|
+
const stored = getStoredPasskey();
|
|
18
|
+
const res = await fetch(API + '/auth', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ passkey: stored }),
|
|
22
|
+
});
|
|
23
|
+
if (res.ok) setAuthed(true);
|
|
24
|
+
} catch {}
|
|
25
|
+
setChecking(false);
|
|
26
|
+
})();
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
if (checking) return null;
|
|
30
|
+
|
|
31
|
+
return html`<${AnimatePresence} mode="wait">
|
|
32
|
+
${showSplash
|
|
33
|
+
? html`<${SplashScreen} key="splash" onDone=${() => setShowSplash(false)} />`
|
|
34
|
+
: !authed
|
|
35
|
+
? html`<${PasskeyScreen} key="passkey" onSuccess=${() => setAuthed(true)} />`
|
|
36
|
+
: html`<${motion.div} key="app"
|
|
37
|
+
style=${{ display: 'flex', height: '100vh', width: '100%' }}
|
|
38
|
+
initial=${{ opacity: 0 }}
|
|
39
|
+
animate=${{ opacity: 1 }}
|
|
40
|
+
transition=${{ duration: 0.4 }}>
|
|
41
|
+
<${App} />
|
|
42
|
+
<//>`}
|
|
43
|
+
<//>`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
3
|
+
import { LuTrash2, LuZap } from "react-icons/lu";
|
|
4
|
+
import { html } from "../lib.js";
|
|
5
|
+
|
|
6
|
+
export function Modal({ title, children, onClose, actions }) {
|
|
7
|
+
return html`<${AnimatePresence}><${motion.div}
|
|
8
|
+
className="modal-overlay"
|
|
9
|
+
onClick=${(e) => e.target === e.currentTarget && onClose()}
|
|
10
|
+
initial=${{ opacity: 0 }} animate=${{ opacity: 1 }} exit=${{ opacity: 0 }}
|
|
11
|
+
transition=${{ duration: 0.15 }}
|
|
12
|
+
>
|
|
13
|
+
<${motion.div} className="modal"
|
|
14
|
+
initial=${{ opacity: 0, scale: 0.95, y: 10 }}
|
|
15
|
+
animate=${{ opacity: 1, scale: 1, y: 0 }}
|
|
16
|
+
exit=${{ opacity: 0, scale: 0.95, y: 10 }}
|
|
17
|
+
transition=${{ duration: 0.15 }}>
|
|
18
|
+
<div className="modal-title">${title}</div>
|
|
19
|
+
${children}
|
|
20
|
+
${actions && html`<div className="modal-actions">${actions}</div>`}
|
|
21
|
+
<//>
|
|
22
|
+
<//><//>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ConfirmDialog({ message, onConfirm, onCancel, confirmLabel, danger }) {
|
|
26
|
+
return html`<${AnimatePresence}><${motion.div}
|
|
27
|
+
className="modal-overlay"
|
|
28
|
+
onClick=${onCancel}
|
|
29
|
+
initial=${{ opacity: 0 }} animate=${{ opacity: 1 }} exit=${{ opacity: 0 }}
|
|
30
|
+
transition=${{ duration: 0.15 }}
|
|
31
|
+
>
|
|
32
|
+
<${motion.div} className="modal" onClick=${e => e.stopPropagation()}
|
|
33
|
+
style=${{ minWidth: '380px', maxWidth: '440px' }}
|
|
34
|
+
initial=${{ opacity: 0, scale: 0.95, y: 10 }}
|
|
35
|
+
animate=${{ opacity: 1, scale: 1, y: 0 }}
|
|
36
|
+
exit=${{ opacity: 0, scale: 0.95, y: 10 }}
|
|
37
|
+
transition=${{ duration: 0.15 }}>
|
|
38
|
+
<div className="flex items-center gap-3" style=${{ marginBottom: '16px' }}>
|
|
39
|
+
<div style=${{ width: '40px', height: '40px', borderRadius: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
|
40
|
+
background: danger ? 'var(--error-glow)' : 'var(--warning-glow)' }}>
|
|
41
|
+
<${danger ? LuTrash2 : LuZap} size=${18} color=${danger ? 'var(--error)' : 'var(--warning)'} />
|
|
42
|
+
</div>
|
|
43
|
+
<div style=${{ fontSize: '15px', fontWeight: 600 }}>Confirm Action</div>
|
|
44
|
+
</div>
|
|
45
|
+
<div style=${{ fontSize: '13px', color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '20px' }}>${message}</div>
|
|
46
|
+
<div className="modal-actions">
|
|
47
|
+
<button className="btn btn-ghost" onClick=${onCancel}>Cancel</button>
|
|
48
|
+
<button className=${`btn ${danger ? 'btn-danger' : 'btn-primary'}`} onClick=${onConfirm}>${confirmLabel || 'Confirm'}</button>
|
|
49
|
+
</div>
|
|
50
|
+
<//>
|
|
51
|
+
<//><//>`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { html, Icons } from "../lib.js";
|
|
3
|
+
import { useApp } from "../context.js";
|
|
4
|
+
|
|
5
|
+
export function Sidebar() {
|
|
6
|
+
const { page, setPage, data } = useApp();
|
|
7
|
+
const dbOk = data.mongoConfigured;
|
|
8
|
+
|
|
9
|
+
const navItems = [
|
|
10
|
+
{
|
|
11
|
+
id: "dashboard",
|
|
12
|
+
label: "Dashboard",
|
|
13
|
+
icon: Icons.dashboard,
|
|
14
|
+
section: "Overview",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "database",
|
|
18
|
+
label: "Database",
|
|
19
|
+
icon: Icons.database,
|
|
20
|
+
section: "Setup",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "schemas",
|
|
24
|
+
label: "Schemas",
|
|
25
|
+
icon: Icons.schema,
|
|
26
|
+
locked: !dbOk,
|
|
27
|
+
lockMsg: "Requires database",
|
|
28
|
+
},
|
|
29
|
+
{ id: "routes", label: "API Routes", icon: Icons.route },
|
|
30
|
+
{ id: "api-tester", label: "API Tester", icon: Icons.apiTester },
|
|
31
|
+
{
|
|
32
|
+
id: "realtime",
|
|
33
|
+
label: "Real-time",
|
|
34
|
+
icon: Icons.realtime,
|
|
35
|
+
section: "Features",
|
|
36
|
+
},
|
|
37
|
+
{ id: "serialport", label: "SerialPort", icon: Icons.serial },
|
|
38
|
+
{ id: "env", label: "Environment", icon: Icons.env, section: "Settings" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
let currentSection = null;
|
|
42
|
+
return html`<div className="sidebar">
|
|
43
|
+
<div className="sidebar-brand">
|
|
44
|
+
<pre>${`██╗ ██╗██████╗ ███╗ ██╗ ██████╗ ██████╗ ███████╗\n██║ ██║██╔══██╗████╗ ██║██╔═══██╗██╔══██╗██╔════╝\n███████║██████╔╝██╔██╗ ██║██║ ██║██║ ██║█████╗\n╚════██║██╔══██╗██║╚██╗██║██║ ██║██║ ██║██╔══╝\n ██║██████╔╝██║ ╚████║╚██████╔╝██████╔╝███████╗\n ╚═╝╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝`}</pre>
|
|
45
|
+
<span>Project Dashboard</span>
|
|
46
|
+
</div>
|
|
47
|
+
${navItems.map((item) => {
|
|
48
|
+
const sectionHeader =
|
|
49
|
+
item.section && item.section !== currentSection
|
|
50
|
+
? ((currentSection = item.section),
|
|
51
|
+
html`<div className="sidebar-section">${item.section}</div>`)
|
|
52
|
+
: null;
|
|
53
|
+
return html`<${React.Fragment} key=${item.id}>
|
|
54
|
+
${sectionHeader}
|
|
55
|
+
<div
|
|
56
|
+
className=${`sidebar-item ${page === item.id ? "active" : ""} ${item.locked ? "locked" : ""}`}
|
|
57
|
+
onClick=${() => !item.locked && setPage(item.id)}
|
|
58
|
+
title=${item.locked ? item.lockMsg : item.label}
|
|
59
|
+
>
|
|
60
|
+
<span className="sidebar-icon">${item.icon}</span>
|
|
61
|
+
${item.label}
|
|
62
|
+
${item.locked &&
|
|
63
|
+
html`<span className="lock-icon">${Icons.lock}</span>`}
|
|
64
|
+
</div>
|
|
65
|
+
<//>`;
|
|
66
|
+
})}
|
|
67
|
+
</div>`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { html } from "../lib.js";
|
|
2
|
+
import { useApp } from "../context.js";
|
|
3
|
+
|
|
4
|
+
export function ToastContainer() {
|
|
5
|
+
const { toasts } = useApp();
|
|
6
|
+
if (toasts.length === 0) return null;
|
|
7
|
+
return html`<div className="toast-container">
|
|
8
|
+
${toasts.map(
|
|
9
|
+
(t) =>
|
|
10
|
+
html`<div key=${t.id} className=${`toast ${t.type}`}>
|
|
11
|
+
${t.message}
|
|
12
|
+
</div>`,
|
|
13
|
+
)}
|
|
14
|
+
</div>`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import htm from "htm";
|
|
3
|
+
import {
|
|
4
|
+
LuLayoutDashboard,
|
|
5
|
+
LuDatabase,
|
|
6
|
+
LuFileText,
|
|
7
|
+
LuRadio,
|
|
8
|
+
LuPlus,
|
|
9
|
+
LuTrash2,
|
|
10
|
+
LuCheck,
|
|
11
|
+
LuLock,
|
|
12
|
+
LuArrowRight,
|
|
13
|
+
LuPencil,
|
|
14
|
+
LuCable,
|
|
15
|
+
LuActivity,
|
|
16
|
+
LuZap,
|
|
17
|
+
LuShield,
|
|
18
|
+
LuBox,
|
|
19
|
+
LuFlaskConical,
|
|
20
|
+
LuSend,
|
|
21
|
+
LuSettings,
|
|
22
|
+
} from "react-icons/lu";
|
|
23
|
+
|
|
24
|
+
export const html = htm.bind(React.createElement);
|
|
25
|
+
export const API = "/_dev/api";
|
|
26
|
+
|
|
27
|
+
export function getStoredPasskey() {
|
|
28
|
+
return sessionStorage.getItem("_dev_passkey") || "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function api(path, options = {}) {
|
|
32
|
+
const passkey = getStoredPasskey();
|
|
33
|
+
const headers = { "Content-Type": "application/json" };
|
|
34
|
+
if (passkey) headers["x-passkey"] = passkey;
|
|
35
|
+
const res = await fetch(API + path, {
|
|
36
|
+
headers,
|
|
37
|
+
...options,
|
|
38
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
39
|
+
});
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let data;
|
|
42
|
+
try {
|
|
43
|
+
data = JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
if (res.ok) return { message: "Done" };
|
|
46
|
+
throw new Error("Server restarting, please wait...");
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok)
|
|
49
|
+
throw new Error(data.error || data.message || "Request failed");
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const Icons = {
|
|
54
|
+
dashboard: html`<${LuLayoutDashboard} size=${16} />`,
|
|
55
|
+
database: html`<${LuDatabase} size=${16} />`,
|
|
56
|
+
schema: html`<${LuFileText} size=${16} />`,
|
|
57
|
+
route: html`<${LuRadio} size=${16} />`,
|
|
58
|
+
realtime: html`<${LuActivity} size=${16} />`,
|
|
59
|
+
env: html`<${LuSettings} size=${16} />`,
|
|
60
|
+
plus: html`<${LuPlus} size=${16} />`,
|
|
61
|
+
trash: html`<${LuTrash2} size=${16} />`,
|
|
62
|
+
check: html`<${LuCheck} size=${16} />`,
|
|
63
|
+
lock: html`<${LuLock} size=${12} />`,
|
|
64
|
+
arrow: html`<${LuArrowRight} size=${14} />`,
|
|
65
|
+
edit: html`<${LuPencil} size=${16} />`,
|
|
66
|
+
serial: html`<${LuCable} size=${16} />`,
|
|
67
|
+
zap: html`<${LuZap} size=${16} />`,
|
|
68
|
+
shield: html`<${LuShield} size=${16} />`,
|
|
69
|
+
box: html`<${LuBox} size=${16} />`,
|
|
70
|
+
apiTester: html`<${LuFlaskConical} size=${16} />`,
|
|
71
|
+
send: html`<${LuSend} size=${16} />`,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const PAGE_TITLES = {
|
|
75
|
+
dashboard: "Dashboard",
|
|
76
|
+
database: "Database",
|
|
77
|
+
schemas: "Schemas",
|
|
78
|
+
routes: "API Routes",
|
|
79
|
+
"api-tester": "API Tester",
|
|
80
|
+
realtime: "Real-time",
|
|
81
|
+
serialport: "SerialPort",
|
|
82
|
+
env: "Environment",
|
|
83
|
+
};
|