4bnode 4.2.0 → 4.2.1
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-bonjour.js +29 -1
- package/add-email.js +33 -15
- package/add-serialport.js +133 -41
- package/index.js +3 -3
- package/lib/codegen.js +1442 -36
- package/lib/codegen.test.js +233 -13
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +116 -7
- package/skeleton/.4bnode/assets/js/components/CommandPalette.js +165 -0
- package/skeleton/.4bnode/assets/js/components/SerialWorkspace.js +634 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +64 -64
- package/skeleton/.4bnode/assets/js/lib.js +76 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +113 -53
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +240 -403
- package/skeleton/.4bnode/assets/js/pages/DiscoveryPage.js +52 -3
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +37 -19
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1 -1
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +5 -5
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +154 -3
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +455 -139
- package/skeleton/.4bnode/codegen.js +1442 -36
- package/skeleton/.4bnode/dev-api.js +568 -95
- package/skeleton/.4bnode/ui.html +1241 -240
- package/skeleton/.env.example +23 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +8 -0
- package/skeleton/public/index.html +345 -237
- package/skeleton/src/discovery.js +128 -6
- package/skeleton/src/middleware/apiKey.js +63 -20
package/lib/codegen.test.js
CHANGED
|
@@ -51,16 +51,33 @@ import {
|
|
|
51
51
|
buildDiscoveryModule,
|
|
52
52
|
wireDiscoveryIntoIndex,
|
|
53
53
|
DISCOVERY_IMPORT,
|
|
54
|
+
buildApiKeyMiddleware,
|
|
55
|
+
wireApiKeyGateIntoIndex,
|
|
56
|
+
API_KEY_GATE_USE,
|
|
57
|
+
generateApiKey,
|
|
58
|
+
hashApiKeyValue,
|
|
59
|
+
buildSerialDeviceModule,
|
|
60
|
+
buildSerialDevicesModule,
|
|
61
|
+
buildSerialRouter,
|
|
62
|
+
normalizeSerialDevice,
|
|
63
|
+
sancharakKeyProblem,
|
|
64
|
+
wireSerialIntoIndex,
|
|
54
65
|
} from "./codegen.js";
|
|
66
|
+
import crypto from "crypto";
|
|
67
|
+
import { execFileSync } from "child_process";
|
|
55
68
|
|
|
56
69
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
57
70
|
const root = path.join(__dirname, "..");
|
|
58
71
|
|
|
59
72
|
let passed = 0;
|
|
73
|
+
// Tests run in order; async tests are awaited. First failure exits non-zero.
|
|
74
|
+
let chain = Promise.resolve();
|
|
60
75
|
function test(name, fn) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
chain = chain.then(async () => {
|
|
77
|
+
await fn();
|
|
78
|
+
passed++;
|
|
79
|
+
console.log(` ✔ ${name}`);
|
|
80
|
+
});
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
// ── Name helpers ────────────────────────────────────────────────────────────
|
|
@@ -507,15 +524,14 @@ test("buildDocsHtml + buildDocsRouter are well-formed, self-contained (no CDN, n
|
|
|
507
524
|
});
|
|
508
525
|
|
|
509
526
|
// ── Email (Resend + SMTP) ────────────────────────────────────────────────────
|
|
510
|
-
test("MAIL_PROVIDERS has exactly
|
|
511
|
-
assert.deepEqual(Object.keys(MAIL_PROVIDERS).sort(), ["
|
|
512
|
-
assert.equal(MAIL_PROVIDERS.
|
|
513
|
-
assert.equal(MAIL_PROVIDERS.
|
|
514
|
-
assert.equal(MAIL_PROVIDERS.
|
|
527
|
+
test("MAIL_PROVIDERS has exactly Sancharak + Custom SMTP", () => {
|
|
528
|
+
assert.deepEqual(Object.keys(MAIL_PROVIDERS).sort(), ["sancharak", "smtp"]);
|
|
529
|
+
assert.equal(MAIL_PROVIDERS.sancharak.label, "Sancharak");
|
|
530
|
+
assert.equal(MAIL_PROVIDERS.sancharak.type, "sancharak");
|
|
531
|
+
assert.equal(MAIL_PROVIDERS.sancharak.apiKeyOnly, true);
|
|
515
532
|
assert.equal(MAIL_PROVIDERS.smtp.type, "smtp");
|
|
516
533
|
assert.equal(MAIL_PROVIDERS.smtp.port, 587);
|
|
517
534
|
});
|
|
518
|
-
|
|
519
535
|
test("official 4brains.in mail domain is gated", () => {
|
|
520
536
|
assert.ok(isOfficialMailDomain("alok@4brains.in"));
|
|
521
537
|
assert.ok(isOfficialMailDomain(" ALOK@4Brains.IN "));
|
|
@@ -525,12 +541,13 @@ test("official 4brains.in mail domain is gated", () => {
|
|
|
525
541
|
assert.equal(OFFICIAL_MAIL_NOTIFY, "pawan@4brains.in");
|
|
526
542
|
});
|
|
527
543
|
|
|
528
|
-
test("buildMailerService supports both
|
|
544
|
+
test("buildMailerService supports both Sancharak and SMTP backends", () => {
|
|
529
545
|
const s = buildMailerService();
|
|
530
546
|
assert.ok(s.includes("MAIL_PROVIDER"), "provider switch");
|
|
531
|
-
assert.ok(
|
|
547
|
+
assert.ok(!/resend/i.test(s), "no Resend left");
|
|
548
|
+
assert.ok(s.includes("https://api.sancharak.com/v1/emails") && s.includes("'Idempotency-Key'"), "Sancharak backend");
|
|
532
549
|
assert.ok(s.includes("await import('nodemailer')") && s.includes("createTransport"), "SMTP backend");
|
|
533
|
-
assert.ok(s.includes("process.env.
|
|
550
|
+
assert.ok(s.includes("process.env.SANCHARAK_API_KEY") && s.includes("process.env.SMTP_HOST"));
|
|
534
551
|
// Runtime gate: 4brains.in from-address is blocked unless authorized.
|
|
535
552
|
assert.ok(s.includes("4brains") && s.includes("OFFICIAL_MAIL_AUTHORIZED"));
|
|
536
553
|
assert.ok(s.includes("export async function sendMail"));
|
|
@@ -539,6 +556,56 @@ test("buildMailerService supports both Resend and SMTP backends", () => {
|
|
|
539
556
|
assert.ok(s.trimEnd().endsWith("};"));
|
|
540
557
|
});
|
|
541
558
|
|
|
559
|
+
test("sancharakKeyProblem rejects shortened and malformed keys", () => {
|
|
560
|
+
assert.match(sancharakKeyProblem("snk_UwtPIq-5\u2026"), /shortened/);
|
|
561
|
+
assert.match(sancharakKeyProblem("snk_abc"), /too short/);
|
|
562
|
+
assert.match(sancharakKeyProblem("re_0123456789012345678901234"), /snk_/);
|
|
563
|
+
assert.match(sancharakKeyProblem("snk_has space_0123456789012345"), /non-ASCII|spaces/);
|
|
564
|
+
assert.equal(sancharakKeyProblem("snk_" + "a".repeat(43)), null);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("mailer sends the Sancharak request shape (mock API)", async () => {
|
|
568
|
+
const http = await import("http");
|
|
569
|
+
const seen = [];
|
|
570
|
+
const srv = http.createServer((req, res) => {
|
|
571
|
+
let body = "";
|
|
572
|
+
req.on("data", (c) => (body += c));
|
|
573
|
+
req.on("end", () => {
|
|
574
|
+
seen.push({ url: req.url, headers: req.headers, body: JSON.parse(body) });
|
|
575
|
+
if (seen.length === 1) { res.writeHead(200, { "content-type": "application/json" }); res.end('{"id":"em_123"}'); }
|
|
576
|
+
else { res.writeHead(422, { "content-type": "application/json" }); res.end('{"message":"from domain not verified"}'); }
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
|
580
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "4bnode-mail-"));
|
|
581
|
+
fs.writeFileSync(path.join(dir, "mailer.mjs"), buildMailerService());
|
|
582
|
+
Object.assign(process.env, {
|
|
583
|
+
MAIL_PROVIDER: "sancharak",
|
|
584
|
+
SANCHARAK_API_KEY: "snk_test_0123456789abcdefghijklmnop",
|
|
585
|
+
SANCHARAK_API_URL: "http://127.0.0.1:" + srv.address().port + "/v1/emails",
|
|
586
|
+
MAIL_FROM: "4Brains <hello@sancharak.com>",
|
|
587
|
+
MAIL_REPLY_TO: "hello@sancharak.com",
|
|
588
|
+
});
|
|
589
|
+
try {
|
|
590
|
+
const { sendMail } = await import(path.join(dir, "mailer.mjs"));
|
|
591
|
+
const out = await sendMail({ to: "you@example.com", subject: "Hi", text: "Hello", html: "<p>Hello</p>" });
|
|
592
|
+
assert.deepEqual(out, { id: "em_123" });
|
|
593
|
+
const r = seen[0];
|
|
594
|
+
assert.equal(r.url, "/v1/emails");
|
|
595
|
+
assert.equal(r.headers.authorization, "Bearer snk_test_0123456789abcdefghijklmnop");
|
|
596
|
+
assert.match(r.headers["idempotency-key"], /^[0-9a-f-]{36}$/);
|
|
597
|
+
assert.deepEqual(r.body, {
|
|
598
|
+
from: "4Brains <hello@sancharak.com>", to: ["you@example.com"], subject: "Hi", stream: "tx",
|
|
599
|
+
reply_to: ["hello@sancharak.com"], text: "Hello", html: "<p>Hello</p>",
|
|
600
|
+
});
|
|
601
|
+
await assert.rejects(() => sendMail({ to: "a@b.c", subject: "x", text: "y" }), /Sancharak 422: from domain not verified/);
|
|
602
|
+
assert.notEqual(seen[1].headers["idempotency-key"], r.headers["idempotency-key"], "fresh key per send");
|
|
603
|
+
await assert.rejects(() => sendMail({ to: "a@b.c", subject: "x", text: "y", cc: "c@d.e" }), /not supported/);
|
|
604
|
+
} finally {
|
|
605
|
+
srv.close();
|
|
606
|
+
for (const k of ["MAIL_PROVIDER", "SANCHARAK_API_KEY", "SANCHARAK_API_URL", "MAIL_FROM", "MAIL_REPLY_TO"]) delete process.env[k];
|
|
607
|
+
}
|
|
608
|
+
});
|
|
542
609
|
// ── Security hardening ──────────────────────────────────────────────────────
|
|
543
610
|
test("buildReadCode paginates and never returns password", () => {
|
|
544
611
|
const out = buildReadCode("User");
|
|
@@ -648,6 +715,153 @@ test("wireDiscoveryIntoIndex is idempotent and wires a bare index.js", () => {
|
|
|
648
715
|
assert.equal(wireDiscoveryIntoIndex(wired), wired, "second run is a no-op");
|
|
649
716
|
});
|
|
650
717
|
|
|
718
|
+
test("Bonjour is off by default and supports choosing a network", () => {
|
|
719
|
+
const mod = buildDiscoveryModule();
|
|
720
|
+
assert.ok(mod.includes('process.env.BONJOUR_ENABLED === "on"'), "only BONJOUR_ENABLED=on enables advertising");
|
|
721
|
+
assert.ok(/export function listNetworkInterfaces\b/.test(mod), "exports listNetworkInterfaces");
|
|
722
|
+
assert.ok(mod.includes("BONJOUR_INTERFACE"), "honors BONJOUR_INTERFACE");
|
|
723
|
+
assert.ok(mod.includes("restrictAddresses(published, iface)"), "advertises only the chosen network's addresses");
|
|
724
|
+
assert.ok(!mod.includes("\\"), "no backslashes (the module is embedded in a template literal)");
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// ── API key gate ────────────────────────────────────────────────────────────
|
|
728
|
+
// Write generated ESM to a temp file and syntax-check it with node itself.
|
|
729
|
+
function assertParses(code, label) {
|
|
730
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "4bnode-"));
|
|
731
|
+
const file = path.join(dir, "check.mjs");
|
|
732
|
+
fs.writeFileSync(file, code);
|
|
733
|
+
try {
|
|
734
|
+
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
|
|
735
|
+
} catch (e) {
|
|
736
|
+
assert.fail(label + " does not parse: " + String(e.stderr || e.message));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
test("skeleton apiKey middleware is generated from buildApiKeyMiddleware (no drift)", () => {
|
|
741
|
+
const onDisk = fs.readFileSync(path.join(root, "skeleton", "src", "middleware", "apiKey.js"), "utf8");
|
|
742
|
+
assert.equal(onDisk, buildApiKeyMiddleware(), "skeleton/src/middleware/apiKey.js is stale");
|
|
743
|
+
assertParses(buildApiKeyMiddleware(), "apiKey middleware");
|
|
744
|
+
assert.ok(/export function apiKeyGate\b/.test(onDisk), "exports apiKeyGate");
|
|
745
|
+
assert.ok(onDisk.includes("timingSafeEqual"), "constant-time comparison");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("apiKeyGate guards every route, login included; /docs, /_dev and preflight stay open", async () => {
|
|
749
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "4bnode-gate-"));
|
|
750
|
+
fs.mkdirSync(path.join(dir, "middleware"));
|
|
751
|
+
fs.writeFileSync(path.join(dir, "middleware", "apiKey.js"), buildApiKeyMiddleware());
|
|
752
|
+
const key = generateApiKey(crypto.randomBytes);
|
|
753
|
+
fs.writeFileSync(path.join(dir, "api-keys.json"), JSON.stringify([{ id: "1", name: "t", hash: hashApiKeyValue(key, crypto.createHash) }]));
|
|
754
|
+
const { apiKeyGate } = await import(path.join(dir, "middleware", "apiKey.js"));
|
|
755
|
+
const call = (method, p, headers = {}) => {
|
|
756
|
+
let status = 200;
|
|
757
|
+
let passed = false;
|
|
758
|
+
const req = { method, path: p, header: (h) => headers[h.toLowerCase()] };
|
|
759
|
+
const res = { status(c) { status = c; return this; }, json() { return this; } };
|
|
760
|
+
apiKeyGate(req, res, () => { passed = true; });
|
|
761
|
+
return passed ? 200 : status;
|
|
762
|
+
};
|
|
763
|
+
assert.equal(call("POST", "/api/login"), 401, "login without a key is rejected");
|
|
764
|
+
assert.equal(call("POST", "/api/register", { "x-api-key": "4b_wrong" }), 401, "wrong key is rejected");
|
|
765
|
+
assert.equal(call("POST", "/api/login", { "x-api-key": key }), 200, "valid key passes");
|
|
766
|
+
assert.equal(call("GET", "/docs"), 200, "/docs is public");
|
|
767
|
+
assert.equal(call("GET", "/docs/openapi.json"), 200, "/docs/* is public");
|
|
768
|
+
assert.equal(call("GET", "/docsx"), 401, "a lookalike prefix is not public");
|
|
769
|
+
assert.equal(call("OPTIONS", "/api/login"), 200, "CORS preflight passes");
|
|
770
|
+
process.env.API_KEY_PUBLIC_PATHS = "/health";
|
|
771
|
+
assert.equal(call("GET", "/health"), 200, "configured public path passes");
|
|
772
|
+
delete process.env.API_KEY_PUBLIC_PATHS;
|
|
773
|
+
process.env.API_KEY_REQUIRED = "off";
|
|
774
|
+
assert.equal(call("GET", "/api/users"), 200, "API_KEY_REQUIRED=off disables the gate");
|
|
775
|
+
delete process.env.API_KEY_REQUIRED;
|
|
776
|
+
assert.ok(!fs.readFileSync(path.join(dir, "api-keys.json"), "utf8").includes(key), "plaintext key is never stored");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test("wireApiKeyGateIntoIndex mounts the gate after static files, idempotently", () => {
|
|
780
|
+
const skeleton = fs.readFileSync(path.join(root, "skeleton", "index.js"), "utf8");
|
|
781
|
+
assert.equal(wireApiKeyGateIntoIndex(skeleton), skeleton, "skeleton already ships the gate");
|
|
782
|
+
const bare = skeleton.replace(/^import \{ apiKeyGate \}.*\n/m, "").replace(/\/\/ ── API key gate[\s\S]*?app\.use\(apiKeyGate\);\n/, "");
|
|
783
|
+
assert.ok(!bare.includes("apiKeyGate"), "fixture has the gate removed");
|
|
784
|
+
const wired = wireApiKeyGateIntoIndex(bare);
|
|
785
|
+
const staticAt = wired.indexOf("app.use(express.static(");
|
|
786
|
+
const gateAt = wired.indexOf(API_KEY_GATE_USE);
|
|
787
|
+
assert.ok(staticAt !== -1 && gateAt > staticAt, "gate comes right after static files");
|
|
788
|
+
assert.ok(gateAt < wired.indexOf("// ── Error handling"), "gate comes before route registration");
|
|
789
|
+
assert.equal(wireApiKeyGateIntoIndex(wired), wired, "second run is a no-op");
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("buildOpenApiSpec adds x-api-key security when the gate is on", () => {
|
|
793
|
+
const endpoints = [
|
|
794
|
+
{ method: "POST", fullPath: "/api/login", bodyFields: [{ name: "email" }] },
|
|
795
|
+
{ method: "GET", fullPath: "/api/me", hasAuth: true },
|
|
796
|
+
];
|
|
797
|
+
const off = buildOpenApiSpec({ endpoints });
|
|
798
|
+
assert.equal(off.security, undefined, "no global security without the gate");
|
|
799
|
+
const on = buildOpenApiSpec({ endpoints, apiKey: true });
|
|
800
|
+
assert.deepEqual(on.components.securitySchemes.apiKeyAuth, { type: "apiKey", in: "header", name: "x-api-key" });
|
|
801
|
+
assert.deepEqual(on.security, [{ apiKeyAuth: [] }], "every operation needs the key");
|
|
802
|
+
assert.deepEqual(on.paths["/api/me"].get.security, [{ apiKeyAuth: [], bearerAuth: [] }], "JWT routes need key AND token");
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// ── Serial devices ──────────────────────────────────────────────────────────
|
|
806
|
+
test("serial modules parse and follow the guide (no hard-coded path, reconnect, queue)", () => {
|
|
807
|
+
assertParses(buildSerialDeviceModule(), "SerialDevice.js");
|
|
808
|
+
assertParses(buildSerialDevicesModule(), "devices.js");
|
|
809
|
+
assertParses(buildSerialRouter(), "routes/serial.js");
|
|
810
|
+
const dev = buildSerialDeviceModule();
|
|
811
|
+
assert.ok(dev.includes("export async function resolvePort"), "resolves the port at connect time");
|
|
812
|
+
assert.ok(dev.includes("withProbeLock"), "probes ports sequentially");
|
|
813
|
+
assert.ok(dev.includes("this._connectLoop();") && dev.includes("port.on('close'"), "reconnects on close");
|
|
814
|
+
assert.ok(dev.includes("_enqueue"), "queues commands");
|
|
815
|
+
assert.ok(dev.includes("_waitReady"), "waits out the board reset after open");
|
|
816
|
+
assert.ok(/export function watchPorts\b/.test(dev), "watches for boards being plugged in");
|
|
817
|
+
assert.ok(dev.includes("waitOrAttach(this.retryMs)"), "an offline device wakes up when a board is attached");
|
|
818
|
+
assert.ok(dev.includes("const initial = !knownPorts"), "boards present at startup are reported too");
|
|
819
|
+
const reg = buildSerialDevicesModule();
|
|
820
|
+
assert.ok(reg.includes("SERIAL_AUTO_ADD") && reg.includes("async function autoAdd"), "unknown boards are auto-added unless disabled");
|
|
821
|
+
assert.ok(/export async function listDevices\b/.test(dev), "lists ports with USB metadata and firmware identity (guide point 7)");
|
|
822
|
+
assert.ok(dev.includes("identities.set(path, v)") && dev.includes("identities.delete(path)"), "remembers identities until the board is unplugged");
|
|
823
|
+
assert.ok(reg.includes("identity.includes(c.match.identity))) return;"), "a replugged identity board is not auto-added twice");
|
|
824
|
+
assert.ok(dev.includes("export async function identifyBoard") && dev.includes("'M115'"), "tries identity presets (ID?, M115, $I, ATI)");
|
|
825
|
+
assert.ok(dev.includes("this.stats.timeouts++") && dev.includes("avgReplyMs"), "tracks device health");
|
|
826
|
+
assert.ok(reg.includes("export const serialBus") && reg.includes("async function runRules"), "live bus + rules engine");
|
|
827
|
+
assert.ok(buildSerialRouter().includes("router.get('/stream'"), "app exposes a live stream");
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("normalizeSerialDevice keeps saved commands and validates rules", () => {
|
|
831
|
+
const d = normalizeSerialDevice({
|
|
832
|
+
name: "printer", match: { serialNumber: "S1" }, auto: true,
|
|
833
|
+
commands: [{ label: "Temps", command: "M105" }, { command: "G28", expectReply: false }, { command: "" }],
|
|
834
|
+
rules: [{ pattern: "Error", url: "https://example.com/h" }, { pattern: "DONE", action: "send", target: "led", command: "LED:ON", cooldownMs: 0 }],
|
|
835
|
+
});
|
|
836
|
+
assert.deepEqual(d.commands, [{ label: "Temps", command: "M105", expectReply: true }, { label: "G28", command: "G28", expectReply: false }]);
|
|
837
|
+
assert.equal(d.rules.length, 2);
|
|
838
|
+
assert.equal(d.rules[0].action, "webhook");
|
|
839
|
+
assert.equal(d.rules[1].cooldownMs, 0);
|
|
840
|
+
assert.equal(d.auto, true);
|
|
841
|
+
assert.throws(() => normalizeSerialDevice({ name: "x", match: { path: "p" }, rules: [{ pattern: "a", url: "ftp://x" }] }), /webhook URL/);
|
|
842
|
+
assert.throws(() => normalizeSerialDevice({ name: "x", match: { path: "p" }, rules: [{ pattern: "(", when: "regex", url: "http://x" }] }), /regular expression/);
|
|
843
|
+
assert.throws(() => normalizeSerialDevice({ name: "x", match: { path: "p" }, rules: [{ pattern: "a", action: "send" }] }), /device and a command/);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test("normalizeSerialDevice validates and keeps one match strategy", () => {
|
|
847
|
+
const d = normalizeSerialDevice({ name: "led", match: { identity: "LED-CTRL", vendorId: "0x10C4", path: "COM5" }, baudRate: "9600" });
|
|
848
|
+
assert.deepEqual(d.match, { identity: "LED-CTRL", vendorId: "10c4" }, "identity wins; path dropped");
|
|
849
|
+
assert.equal(d.baudRate, 9600);
|
|
850
|
+
assert.equal(d.delimiter, "lf");
|
|
851
|
+
assert.throws(() => normalizeSerialDevice({ name: "1bad", match: { path: "COM1" } }), /Device name/);
|
|
852
|
+
assert.throws(() => normalizeSerialDevice({ name: "x", match: {} }), /Choose how to find/);
|
|
853
|
+
assert.throws(() => normalizeSerialDevice({ name: "x", match: { vendorId: "zz" } }), /4 hex digits/);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("wireSerialIntoIndex mounts routes after the gate and closes ports on shutdown", () => {
|
|
857
|
+
const skeleton = fs.readFileSync(path.join(root, "skeleton", "index.js"), "utf8");
|
|
858
|
+
const wired = wireSerialIntoIndex(skeleton);
|
|
859
|
+
assert.ok(wired.indexOf('app.use("/api/serial", serialRouter);') > wired.indexOf(API_KEY_GATE_USE), "serial routes are behind the API key gate");
|
|
860
|
+
assert.ok(wired.includes("startSerialDevices()"), "starts devices at boot");
|
|
861
|
+
assert.ok(wired.includes("await stopSerialDevices().catch(() => {}); await destroyDiscovery();"), "closes ports in the existing shutdown handlers");
|
|
862
|
+
assert.equal(wireSerialIntoIndex(wired), wired, "second run is a no-op");
|
|
863
|
+
});
|
|
864
|
+
|
|
651
865
|
// ── Skeleton drift guard ────────────────────────────────────────────────────
|
|
652
866
|
test("skeleton codegen.js is in sync with lib/codegen.js", () => {
|
|
653
867
|
const src = fs.readFileSync(path.join(root, "lib", "codegen.js"), "utf8");
|
|
@@ -659,4 +873,10 @@ test("skeleton codegen.js is in sync with lib/codegen.js", () => {
|
|
|
659
873
|
);
|
|
660
874
|
});
|
|
661
875
|
|
|
662
|
-
|
|
876
|
+
chain.then(
|
|
877
|
+
() => console.log(`\n${passed} passed`),
|
|
878
|
+
(err) => {
|
|
879
|
+
console.error(err);
|
|
880
|
+
process.exit(1);
|
|
881
|
+
},
|
|
882
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "4bnode",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.1",
|
|
4
4
|
"description": "4bnode is a CLI-powered backend development platform with a built-in visual dashboard to generate, manage, and test Node.js/Express APIs faster.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { motion, AnimatePresence } from "framer-motion";
|
|
3
|
-
import { html, api, PAGE_TITLES } from "./lib.js";
|
|
3
|
+
import { html, api, PAGE_TITLES, NAV, Icons, MOD_KEY, isTypingTarget } from "./lib.js";
|
|
4
4
|
import { AppContext } from "./context.js";
|
|
5
5
|
import { Sidebar } from "./components/Sidebar.js";
|
|
6
6
|
import { ToastContainer } from "./components/ToastContainer.js";
|
|
7
7
|
import { ConfirmDialog } from "./components/Modal.js";
|
|
8
|
+
import { CommandPalette, ShortcutsDialog } from "./components/CommandPalette.js";
|
|
8
9
|
import { DashboardPage } from "./pages/DashboardPage.js";
|
|
9
10
|
import { DatabasePage } from "./pages/DatabasePage.js";
|
|
10
11
|
import { SchemasPage } from "./pages/SchemasPage.js";
|
|
@@ -36,6 +37,21 @@ export function App() {
|
|
|
36
37
|
const [status, setStatus] = useState({});
|
|
37
38
|
const [toasts, setToasts] = useState([]);
|
|
38
39
|
const [confirmState, setConfirmState] = useState(null);
|
|
40
|
+
const [health, setHealth] = useState(null);
|
|
41
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
42
|
+
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
43
|
+
const [mobileNav, setMobileNav] = useState(false);
|
|
44
|
+
const [collapsed, setCollapsed] = useState(() => {
|
|
45
|
+
try { return localStorage.getItem("_4bnode_sidebar") === "collapsed"; } catch { return false; }
|
|
46
|
+
});
|
|
47
|
+
const toggleCollapsed = useCallback(() => {
|
|
48
|
+
setCollapsed((c) => {
|
|
49
|
+
try { localStorage.setItem("_4bnode_sidebar", c ? "expanded" : "collapsed"); } catch {}
|
|
50
|
+
return !c;
|
|
51
|
+
});
|
|
52
|
+
}, []);
|
|
53
|
+
const openPalette = useCallback(() => { setMobileNav(false); setPaletteOpen(true); }, []);
|
|
54
|
+
const openShortcuts = useCallback(() => setShortcutsOpen(true), []);
|
|
39
55
|
|
|
40
56
|
const showConfirm = useCallback((message, opts = {}) => {
|
|
41
57
|
return new Promise((resolve) => {
|
|
@@ -92,6 +108,79 @@ export function App() {
|
|
|
92
108
|
refresh().catch(() => {});
|
|
93
109
|
}, []);
|
|
94
110
|
|
|
111
|
+
// Live process + traffic stats (Sidebar status, Topbar, Dashboard overview).
|
|
112
|
+
// Polls only while the tab is visible.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
let timer;
|
|
115
|
+
async function poll() {
|
|
116
|
+
if (document.visibilityState === "visible") {
|
|
117
|
+
try { setHealth(await api("/health")); } catch { setHealth(null); }
|
|
118
|
+
}
|
|
119
|
+
timer = setTimeout(poll, 5000);
|
|
120
|
+
}
|
|
121
|
+
poll();
|
|
122
|
+
return () => clearTimeout(timer);
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
// USB serial hotplug: toast when a board is plugged in, removed or
|
|
126
|
+
// auto-added, on whatever page is open. Events from before this page
|
|
127
|
+
// loaded are skipped. Pages listen for "serial-event" to refresh.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!status.serialportConfigured) return;
|
|
130
|
+
let lastId = null;
|
|
131
|
+
let timer;
|
|
132
|
+
async function poll() {
|
|
133
|
+
if (document.visibilityState === "visible") {
|
|
134
|
+
try {
|
|
135
|
+
const events = await api("/serial/events?since=" + (lastId || 0));
|
|
136
|
+
if (lastId === null) {
|
|
137
|
+
lastId = events.length ? events[events.length - 1].id : 0;
|
|
138
|
+
} else {
|
|
139
|
+
for (const e of events) {
|
|
140
|
+
lastId = e.id;
|
|
141
|
+
const chip = e.vendor ? " (" + e.vendor + ")" : "";
|
|
142
|
+
if (e.type === "attach") toast("USB board connected on " + e.path + chip, "info");
|
|
143
|
+
else if (e.type === "detach") toast("USB board removed from " + e.path, "info");
|
|
144
|
+
else if (e.type === "added") toast('New board added as "' + e.name + '"', "success");
|
|
145
|
+
window.dispatchEvent(new CustomEvent("serial-event", { detail: e }));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch {}
|
|
149
|
+
}
|
|
150
|
+
timer = setTimeout(poll, 2500);
|
|
151
|
+
}
|
|
152
|
+
poll();
|
|
153
|
+
return () => clearTimeout(timer);
|
|
154
|
+
}, [status.serialportConfigured]);
|
|
155
|
+
|
|
156
|
+
// Global shortcuts: ⌘K palette, ? help, [ sidebar, g <key> page jump.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
let pendingG = 0;
|
|
159
|
+
function onKey(e) {
|
|
160
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
setPaletteOpen((o) => !o);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (e.metaKey || e.ctrlKey || e.altKey || isTypingTarget(e.target)) return;
|
|
166
|
+
if (document.querySelector(".palette-overlay, .modal-overlay")) return;
|
|
167
|
+
const now = Date.now();
|
|
168
|
+
if (pendingG && now - pendingG < 1200) {
|
|
169
|
+
pendingG = 0;
|
|
170
|
+
const target = NAV.find((n) => n.key === e.key.toLowerCase());
|
|
171
|
+
if (target) { e.preventDefault(); setPage(target.id); }
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (e.key === "g") { pendingG = now; return; }
|
|
175
|
+
if (e.key === "?") { e.preventDefault(); setShortcutsOpen(true); }
|
|
176
|
+
else if (e.key === "[") { e.preventDefault(); toggleCollapsed(); }
|
|
177
|
+
}
|
|
178
|
+
window.addEventListener("keydown", onKey);
|
|
179
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
useEffect(() => { document.title = `${PAGE_TITLES[page] || "Dashboard"} · 4bnode`; }, [page]);
|
|
183
|
+
|
|
95
184
|
const pages = {
|
|
96
185
|
dashboard: html`<${DashboardPage} />`,
|
|
97
186
|
"ai-builder": html`<${AiBuilderPage} />`,
|
|
@@ -110,14 +199,32 @@ export function App() {
|
|
|
110
199
|
};
|
|
111
200
|
|
|
112
201
|
return html`<${AppContext.Provider}
|
|
113
|
-
value=${{
|
|
202
|
+
value=${{
|
|
203
|
+
page, setPage, data, status, toasts, toast, refresh, showConfirm, health,
|
|
204
|
+
collapsed, toggleCollapsed, mobileNav, setMobileNav, openPalette, openShortcuts,
|
|
205
|
+
}}
|
|
114
206
|
>
|
|
115
207
|
<${Sidebar} />
|
|
116
208
|
<div className="main">
|
|
117
|
-
<
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
209
|
+
<header className="topbar">
|
|
210
|
+
<button className="topbar-menu" onClick=${() => setMobileNav(true)} aria-label="Open navigation">${Icons.menu}</button>
|
|
211
|
+
<div className="topbar-crumbs">
|
|
212
|
+
<span className="crumb-muted">${NAV.find((n) => n.id === page)?.section || "Overview"}</span>
|
|
213
|
+
<span className="crumb-sep">/</span>
|
|
214
|
+
<h2>${PAGE_TITLES[page] || "Dashboard"}</h2>
|
|
215
|
+
</div>
|
|
216
|
+
<div className="topbar-actions">
|
|
217
|
+
<button className="topbar-search" onClick=${openPalette}>
|
|
218
|
+
${Icons.search}<span>Search or jump to…</span><kbd>${MOD_KEY}K</kbd>
|
|
219
|
+
</button>
|
|
220
|
+
<span className=${`live-pill ${health ? "on" : "off"}`} title=${health ? `Uptime ${Math.round(health.uptime / 60)}m · pid ${health.pid}` : "Server unreachable"}>
|
|
221
|
+
<span className="status-dot" />${health ? "Live" : "Offline"}
|
|
222
|
+
</span>
|
|
223
|
+
<a className="btn-icon-ghost" href="/" target="_blank" rel="noopener" title="Open app">${Icons.external}</a>
|
|
224
|
+
<button className="btn-icon-ghost" onClick=${openShortcuts} title="Keyboard shortcuts (?)">${Icons.keyboard}</button>
|
|
225
|
+
</div>
|
|
226
|
+
</header>
|
|
227
|
+
<main className="content">
|
|
121
228
|
<${AnimatePresence} mode="wait">
|
|
122
229
|
<${motion.div} key=${page}
|
|
123
230
|
initial=${{ opacity: 0, y: 8 }}
|
|
@@ -127,9 +234,11 @@ export function App() {
|
|
|
127
234
|
${pages[page] || pages.dashboard}
|
|
128
235
|
<//>
|
|
129
236
|
<//>
|
|
130
|
-
</
|
|
237
|
+
</main>
|
|
131
238
|
</div>
|
|
132
239
|
<${ToastContainer} />
|
|
240
|
+
${paletteOpen && html`<${CommandPalette} onClose=${() => setPaletteOpen(false)} />`}
|
|
241
|
+
${shortcutsOpen && html`<${ShortcutsDialog} onClose=${() => setShortcutsOpen(false)} />`}
|
|
133
242
|
${confirmState && html`<${ConfirmDialog}
|
|
134
243
|
message=${confirmState.message}
|
|
135
244
|
confirmLabel=${confirmState.confirmLabel}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { motion } from "framer-motion";
|
|
3
|
+
import { html, Icons, NAV, MOD_KEY, api, copyToClipboard } from "../lib.js";
|
|
4
|
+
import { useApp } from "../context.js";
|
|
5
|
+
|
|
6
|
+
// Subsequence fuzzy match: every query char must appear in order. Lower score
|
|
7
|
+
// = better (earlier, tighter matches win). Returns null when it doesn't match.
|
|
8
|
+
function fuzzyScore(text, query) {
|
|
9
|
+
if (!query) return 0;
|
|
10
|
+
const t = text.toLowerCase();
|
|
11
|
+
const q = query.toLowerCase();
|
|
12
|
+
const direct = t.indexOf(q);
|
|
13
|
+
if (direct !== -1) return direct;
|
|
14
|
+
let ti = 0, score = 100, last = -1;
|
|
15
|
+
for (const ch of q) {
|
|
16
|
+
const i = t.indexOf(ch, ti);
|
|
17
|
+
if (i === -1) return null;
|
|
18
|
+
score += last === -1 ? i : i - last - 1;
|
|
19
|
+
last = i;
|
|
20
|
+
ti = i + 1;
|
|
21
|
+
}
|
|
22
|
+
return score;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function CommandPalette({ onClose }) {
|
|
26
|
+
const { setPage, data, status, refresh, toast, toggleCollapsed, openShortcuts } = useApp();
|
|
27
|
+
const [query, setQuery] = useState("");
|
|
28
|
+
const [active, setActive] = useState(0);
|
|
29
|
+
const inputRef = useRef(null);
|
|
30
|
+
const listRef = useRef(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
33
|
+
|
|
34
|
+
const items = useMemo(() => {
|
|
35
|
+
const appUrl = window.location.origin;
|
|
36
|
+
const list = [];
|
|
37
|
+
NAV.forEach((n) => {
|
|
38
|
+
const locked = n.needsDb && !data.mongoConfigured;
|
|
39
|
+
if (!locked) list.push({ group: "Pages", label: n.label, icon: n.icon, hint: n.key ? `G ${n.key.toUpperCase()}` : "", run: () => setPage(n.id) });
|
|
40
|
+
});
|
|
41
|
+
(data.models || []).forEach((m) => list.push({
|
|
42
|
+
group: "Models", label: m.name, sub: `${m.fields?.length || 0} fields`, icon: Icons.schema, run: () => setPage("schemas"),
|
|
43
|
+
}));
|
|
44
|
+
(data.routes || []).forEach((r) => list.push({
|
|
45
|
+
group: "Routes", label: r.replace(/\.js$/, ""), sub: `src/routes/${r}`, icon: Icons.route, run: () => setPage("routes"),
|
|
46
|
+
}));
|
|
47
|
+
list.push(
|
|
48
|
+
{ group: "Actions", label: "New request in API Tester", icon: Icons.send, run: () => setPage("api-tester") },
|
|
49
|
+
{ group: "Actions", label: "Refresh project state", icon: Icons.refresh, run: () => refresh().then(() => toast("Refreshed", "success")).catch(() => toast("Refresh failed", "error")) },
|
|
50
|
+
{ group: "Actions", label: "Open app in new tab", sub: appUrl, icon: Icons.external, run: () => window.open(appUrl, "_blank", "noopener") },
|
|
51
|
+
{ group: "Actions", label: "Copy app URL", sub: appUrl, icon: Icons.external, run: () => copyToClipboard(appUrl).then(() => toast("Copied " + appUrl, "success")) },
|
|
52
|
+
{ group: "Actions", label: "Toggle sidebar", icon: Icons.collapse, hint: "[", run: toggleCollapsed },
|
|
53
|
+
{ group: "Actions", label: "Keyboard shortcuts", icon: Icons.keyboard, hint: "?", run: openShortcuts },
|
|
54
|
+
{ group: "Actions", label: "Clear logs", icon: Icons.logs, run: () => api("/logs", { method: "DELETE" }).then(() => toast("Logs cleared", "success")).catch((e) => toast(e.message, "error")) },
|
|
55
|
+
);
|
|
56
|
+
if (status.docsConfigured) {
|
|
57
|
+
list.push(
|
|
58
|
+
{ group: "Actions", label: "Open API docs", sub: appUrl + "/docs", icon: Icons.docs, run: () => window.open(appUrl + "/docs", "_blank", "noopener") },
|
|
59
|
+
{ group: "Actions", label: "Regenerate API docs", icon: Icons.docs, run: () => api("/docs/regenerate", { method: "POST" }).then(() => toast("Docs regenerated", "success")).catch((e) => toast(e.message, "error")) },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return list;
|
|
63
|
+
}, [data, status]);
|
|
64
|
+
|
|
65
|
+
const results = useMemo(() => {
|
|
66
|
+
if (!query.trim()) return items;
|
|
67
|
+
return items
|
|
68
|
+
.map((it) => ({ it, s: fuzzyScore(`${it.label} ${it.sub || ""}`, query.trim()) }))
|
|
69
|
+
.filter((r) => r.s !== null)
|
|
70
|
+
.sort((a, b) => a.s - b.s)
|
|
71
|
+
.map((r) => r.it)
|
|
72
|
+
// Keep groups contiguous, ordered by each group's best match.
|
|
73
|
+
.reduce((acc, it) => {
|
|
74
|
+
(acc.find((g) => g[0].group === it.group) || acc[acc.push([]) - 1]).push(it);
|
|
75
|
+
return acc;
|
|
76
|
+
}, [])
|
|
77
|
+
.flat();
|
|
78
|
+
}, [items, query]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => setActive(0), [query]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
listRef.current?.querySelector(`[data-idx="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
|
83
|
+
}, [active]);
|
|
84
|
+
|
|
85
|
+
function run(item) {
|
|
86
|
+
if (!item) return;
|
|
87
|
+
onClose();
|
|
88
|
+
item.run();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function onKeyDown(e) {
|
|
92
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(results.length - 1, a + 1)); }
|
|
93
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); }
|
|
94
|
+
else if (e.key === "Enter") { e.preventDefault(); run(results[active]); }
|
|
95
|
+
else if (e.key === "Escape") { e.preventDefault(); onClose(); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let lastGroup = null;
|
|
99
|
+
return html`<${motion.div} className="palette-overlay" onMouseDown=${(e) => e.target === e.currentTarget && onClose()}
|
|
100
|
+
initial=${{ opacity: 0 }} animate=${{ opacity: 1 }} transition=${{ duration: 0.12 }}>
|
|
101
|
+
<${motion.div} className="palette" role="dialog" aria-label="Command palette"
|
|
102
|
+
initial=${{ opacity: 0, scale: 0.98, y: -6 }} animate=${{ opacity: 1, scale: 1, y: 0 }} transition=${{ duration: 0.12 }}>
|
|
103
|
+
<div className="palette-input">
|
|
104
|
+
${Icons.search}
|
|
105
|
+
<input ref=${inputRef} value=${query} placeholder="Search pages, models, routes, actions…"
|
|
106
|
+
onInput=${(e) => setQuery(e.target.value)} onKeyDown=${onKeyDown} />
|
|
107
|
+
<kbd>Esc</kbd>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="palette-list" ref=${listRef}>
|
|
110
|
+
${results.length === 0 && html`<div className="palette-empty">No results for “${query}”</div>`}
|
|
111
|
+
${results.map((it, i) => {
|
|
112
|
+
const header = it.group !== lastGroup ? ((lastGroup = it.group), html`<div className="palette-group">${it.group}</div>`) : null;
|
|
113
|
+
return html`<${React.Fragment} key=${it.group + it.label + i}>
|
|
114
|
+
${header}
|
|
115
|
+
<div data-idx=${i} className=${`palette-item${i === active ? " active" : ""}`}
|
|
116
|
+
onMouseMove=${() => active !== i && setActive(i)} onClick=${() => run(it)}>
|
|
117
|
+
<span className="palette-icon">${it.icon}</span>
|
|
118
|
+
<span className="palette-label">${it.label}</span>
|
|
119
|
+
${it.sub && html`<span className="palette-sub">${it.sub}</span>`}
|
|
120
|
+
${it.hint && html`<kbd>${it.hint}</kbd>`}
|
|
121
|
+
</div>
|
|
122
|
+
<//>`;
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
<div className="palette-footer">
|
|
126
|
+
<span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
|
|
127
|
+
<span><kbd>↵</kbd> select</span>
|
|
128
|
+
<span><kbd>esc</kbd> close</span>
|
|
129
|
+
</div>
|
|
130
|
+
<//>
|
|
131
|
+
<//>`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function ShortcutsDialog({ onClose }) {
|
|
135
|
+
const mod = MOD_KEY;
|
|
136
|
+
const groups = [
|
|
137
|
+
{ title: "General", rows: [[`${mod} K`, "Command palette"], ["?", "Show shortcuts"], ["[", "Toggle sidebar"], ["Esc", "Close dialog"]] },
|
|
138
|
+
{ title: "Go to", rows: NAV.filter((n) => n.key).map((n) => [`G ${n.key.toUpperCase()}`, n.label]) },
|
|
139
|
+
{ title: "API Tester", rows: [[`${mod} ↵`, "Send request"], ["Alt N", "New request tab"], ["Alt W", "Close request tab"], ["Alt H", "Toggle history"]] },
|
|
140
|
+
];
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const onKey = (e) => e.key === "Escape" && onClose();
|
|
143
|
+
window.addEventListener("keydown", onKey);
|
|
144
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
145
|
+
}, []);
|
|
146
|
+
return html`<${motion.div} className="palette-overlay" onMouseDown=${(e) => e.target === e.currentTarget && onClose()}
|
|
147
|
+
initial=${{ opacity: 0 }} animate=${{ opacity: 1 }} transition=${{ duration: 0.12 }}>
|
|
148
|
+
<${motion.div} className="palette shortcuts" role="dialog" aria-label="Keyboard shortcuts"
|
|
149
|
+
initial=${{ opacity: 0, scale: 0.98 }} animate=${{ opacity: 1, scale: 1 }} transition=${{ duration: 0.12 }}>
|
|
150
|
+
<div className="shortcuts-head">
|
|
151
|
+
<span>Keyboard shortcuts</span>
|
|
152
|
+
<button className="btn-icon-ghost" onClick=${onClose} aria-label="Close">${Icons.close}</button>
|
|
153
|
+
</div>
|
|
154
|
+
<div className="shortcuts-body">
|
|
155
|
+
${groups.map((g) => html`<div key=${g.title} className="shortcuts-group">
|
|
156
|
+
<div className="palette-group">${g.title}</div>
|
|
157
|
+
${g.rows.map(([k, label]) => html`<div key=${k} className="shortcut-row">
|
|
158
|
+
<span>${label}</span>
|
|
159
|
+
<span>${k.split(" ").map((p) => html`<kbd key=${p}>${p}</kbd>`)}</span>
|
|
160
|
+
</div>`)}
|
|
161
|
+
</div>`)}
|
|
162
|
+
</div>
|
|
163
|
+
<//>
|
|
164
|
+
<//>`;
|
|
165
|
+
}
|