Initial Commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(bun x *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.env*
|
||||
*.db
|
||||
.git
|
||||
.claude
|
||||
@@ -0,0 +1,14 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build:
|
||||
runs-on: remote
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build
|
||||
run: docker buildx build --push -t ${{ vars.DOCKER_REGISTRY }}/sb/bot-manager:latest .
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# database
|
||||
data/
|
||||
*.db
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
@@ -0,0 +1,106 @@
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
FROM oven/bun:1-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# ── dev ───────────────────────────────────────────────────────────────────────
|
||||
# src/ is bind-mounted at runtime; only deps are baked in.
|
||||
FROM base AS dev
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "--hot", "src/index.ts"]
|
||||
|
||||
# ── prod ──────────────────────────────────────────────────────────────────────
|
||||
FROM base AS prod
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY src ./src
|
||||
RUN mkdir -p data
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "src/index.ts"]
|
||||
@@ -0,0 +1,21 @@
|
||||
# bun-react-tailwind-template
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To start a development server:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
To run for production:
|
||||
|
||||
```bash
|
||||
bun start
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env bun
|
||||
import plugin from "bun-plugin-tailwind";
|
||||
import { existsSync } from "fs";
|
||||
import { rm } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||
console.log(`
|
||||
🏗️ Bun Build Script
|
||||
|
||||
Usage: bun run build.ts [options]
|
||||
|
||||
Common Options:
|
||||
--outdir <path> Output directory (default: "dist")
|
||||
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
|
||||
--sourcemap <type> Sourcemap type: none|linked|inline|external
|
||||
--target <target> Build target: browser|bun|node
|
||||
--format <format> Output format: esm|cjs|iife
|
||||
--splitting Enable code splitting
|
||||
--packages <type> Package handling: bundle|external
|
||||
--public-path <path> Public path for assets
|
||||
--env <mode> Environment handling: inline|disable|prefix*
|
||||
--conditions <list> Package.json export conditions (comma separated)
|
||||
--external <list> External packages (comma separated)
|
||||
--banner <text> Add banner text to output
|
||||
--footer <text> Add footer text to output
|
||||
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
|
||||
--help, -h Show this help message
|
||||
|
||||
Example:
|
||||
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
|
||||
|
||||
const parseValue = (value: string): any => {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
|
||||
if (/^\d+$/.test(value)) return parseInt(value, 10);
|
||||
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
|
||||
|
||||
if (value.includes(",")) return value.split(",").map(v => v.trim());
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
function parseArgs(): Partial<Bun.BuildConfig> {
|
||||
const config: Partial<Bun.BuildConfig> = {};
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === undefined) continue;
|
||||
if (!arg.startsWith("--")) continue;
|
||||
|
||||
if (arg.startsWith("--no-")) {
|
||||
const key = toCamelCase(arg.slice(5));
|
||||
config[key] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
|
||||
const key = toCamelCase(arg.slice(2));
|
||||
config[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let key: string;
|
||||
let value: string;
|
||||
|
||||
if (arg.includes("=")) {
|
||||
[key, value] = arg.slice(2).split("=", 2) as [string, string];
|
||||
} else {
|
||||
key = arg.slice(2);
|
||||
value = args[++i] ?? "";
|
||||
}
|
||||
|
||||
key = toCamelCase(key);
|
||||
|
||||
if (key.includes(".")) {
|
||||
const [parentKey, childKey] = key.split(".");
|
||||
config[parentKey] = config[parentKey] || {};
|
||||
config[parentKey][childKey] = parseValue(value);
|
||||
} else {
|
||||
config[key] = parseValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
console.log("\n🚀 Starting build process...\n");
|
||||
|
||||
const cliConfig = parseArgs();
|
||||
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
|
||||
|
||||
if (existsSync(outdir)) {
|
||||
console.log(`🗑️ Cleaning previous build at ${outdir}`);
|
||||
await rm(outdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
|
||||
.map(a => path.resolve("src", a))
|
||||
.filter(dir => !dir.includes("node_modules"));
|
||||
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints,
|
||||
outdir,
|
||||
plugins: [plugin],
|
||||
minify: true,
|
||||
target: "browser",
|
||||
sourcemap: "linked",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
},
|
||||
...cliConfig,
|
||||
});
|
||||
|
||||
const end = performance.now();
|
||||
|
||||
const outputTable = result.outputs.map(output => ({
|
||||
File: path.relative(process.cwd(), output.path),
|
||||
Type: output.kind,
|
||||
Size: formatFileSize(output.size),
|
||||
}));
|
||||
|
||||
console.table(outputTable);
|
||||
const buildTime = (end - start).toFixed(2);
|
||||
|
||||
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
// Generated by `bun init`
|
||||
|
||||
declare module "*.svg" {
|
||||
/**
|
||||
* A path to the SVG file
|
||||
*/
|
||||
const path: `${string}.svg`;
|
||||
export = path;
|
||||
}
|
||||
|
||||
declare module "*.module.css" {
|
||||
/**
|
||||
* A record of class names to their corresponding CSS module classes
|
||||
*/
|
||||
const classes: { readonly [key: string]: string };
|
||||
export = classes;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
"dependencies": {
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"tailwindcss": "^4.1.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@oven/bun-darwin-aarch64": ["@oven/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="],
|
||||
|
||||
"@oven/bun-darwin-x64": ["@oven/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="],
|
||||
|
||||
"@oven/bun-darwin-x64-baseline": ["@oven/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="],
|
||||
|
||||
"@oven/bun-freebsd-aarch64": ["@oven/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="],
|
||||
|
||||
"@oven/bun-freebsd-x64": ["@oven/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="],
|
||||
|
||||
"@oven/bun-linux-aarch64": ["@oven/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-android": ["@oven/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="],
|
||||
|
||||
"@oven/bun-linux-aarch64-musl": ["@oven/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="],
|
||||
|
||||
"@oven/bun-linux-x64": ["@oven/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="],
|
||||
|
||||
"@oven/bun-linux-x64-android": ["@oven/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="],
|
||||
|
||||
"@oven/bun-linux-x64-baseline": ["@oven/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl": ["@oven/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="],
|
||||
|
||||
"@oven/bun-linux-x64-musl-baseline": ["@oven/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="],
|
||||
|
||||
"@oven/bun-windows-aarch64": ["@oven/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="],
|
||||
|
||||
"@oven/bun-windows-x64": ["@oven/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="],
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
|
||||
|
||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
||||
|
||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
|
||||
|
||||
"bun": ["[email protected]", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
|
||||
|
||||
"bun-plugin-tailwind": ["[email protected]", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"react": ["[email protected]", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
|
||||
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"tailwindcss": ["[email protected]", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
dev:
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
volumes:
|
||||
- ./src:/app/src:ro
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
prod:
|
||||
build:
|
||||
context: .
|
||||
target: prod
|
||||
volumes:
|
||||
- bot_data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
bot_data:
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "bun-react-template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/index.ts",
|
||||
"start": "NODE_ENV=production bun src/index.ts",
|
||||
"build": "bun run build.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"tailwindcss": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useRef, type FormEvent } from "react";
|
||||
|
||||
export function APITester() {
|
||||
const responseInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const testEndpoint = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const endpoint = formData.get("endpoint") as string;
|
||||
const url = new URL(endpoint, location.href);
|
||||
const method = formData.get("method") as string;
|
||||
const res = await fetch(url, { method });
|
||||
|
||||
const data = await res.json();
|
||||
responseInputRef.current!.value = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
responseInputRef.current!.value = String(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-8 mx-auto w-full max-w-2xl text-left flex flex-col gap-4">
|
||||
<form
|
||||
onSubmit={testEndpoint}
|
||||
className="flex items-center gap-2 bg-[#1a1a1a] p-3 rounded-xl font-mono border-2 border-[#fbf0df] transition-colors duration-300 focus-within:border-[#f3d5a3] w-full"
|
||||
>
|
||||
<select
|
||||
name="method"
|
||||
className="bg-[#fbf0df] text-[#1a1a1a] py-1.5 px-3 rounded-lg font-bold text-sm min-w-[0px] appearance-none cursor-pointer hover:bg-[#f3d5a3] transition-colors duration-100"
|
||||
>
|
||||
<option value="GET" className="py-1">
|
||||
GET
|
||||
</option>
|
||||
<option value="PUT" className="py-1">
|
||||
PUT
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="endpoint"
|
||||
defaultValue="/api/hello"
|
||||
className="w-full flex-1 bg-transparent border-0 text-[#fbf0df] font-mono text-base py-1.5 px-2 outline-none focus:text-white placeholder-[#fbf0df]/40"
|
||||
placeholder="/api/hello"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#fbf0df] text-[#1a1a1a] border-0 px-5 py-1.5 rounded-lg font-bold transition-all duration-100 hover:bg-[#f3d5a3] hover:-translate-y-px cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
<textarea
|
||||
ref={responseInputRef}
|
||||
readOnly
|
||||
placeholder="Response will appear here..."
|
||||
className="w-full min-h-[140px] bg-[#1a1a1a] border-2 border-[#fbf0df] rounded-xl p-3 text-[#fbf0df] font-mono resize-y focus:border-[#f3d5a3] placeholder-[#fbf0df]/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { BotConfig, BotStatus, S2CMessage } from "./types";
|
||||
|
||||
interface LogEntry {
|
||||
direction: 'sent' | 'received';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(ts: number) {
|
||||
return new Date(ts).toLocaleTimeString('en-US', { hour12: false });
|
||||
}
|
||||
|
||||
function parseReceived(raw: string): { label: string; cls: string } {
|
||||
if (raw === 'warp.success') return { label: 'Warp successful', cls: 'text-green-400' };
|
||||
if (raw.startsWith('warp.error ')) return { label: `Warp error: ${raw.slice(11)}`, cls: 'text-red-400' };
|
||||
if (raw === 'bot.connected') return { label: 'Bot connected to Minecraft', cls: 'text-green-400' };
|
||||
if (raw === 'bot.reconnecting') return { label: 'Bot reconnecting to Minecraft…', cls: 'text-yellow-400' };
|
||||
return { label: raw, cls: 'text-gray-300' };
|
||||
}
|
||||
|
||||
// ─── small components ─────────────────────────────────────────────────────────
|
||||
|
||||
function Dot({ status, title }: { status: string; title: string }) {
|
||||
return (
|
||||
<span
|
||||
title={`${title}: ${status}`}
|
||||
className={`status-dot ${status}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageLog({ entries }: { entries: LogEntry[] }) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [entries]);
|
||||
|
||||
return (
|
||||
<div className="message-log">
|
||||
{entries.length === 0 && (
|
||||
<span className="text-gray-600 italic text-xs">No messages yet.</span>
|
||||
)}
|
||||
{entries.map((e, i) => {
|
||||
if (e.direction === 'sent') {
|
||||
return (
|
||||
<div key={i} className="log-row">
|
||||
<span className="log-time">{fmtTime(e.timestamp)}</span>
|
||||
<span className="log-arrow-sent">→</span>
|
||||
<span className="text-blue-300">{e.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { label, cls } = parseReceived(e.content);
|
||||
return (
|
||||
<div key={i} className="log-row">
|
||||
<span className="log-time">{fmtTime(e.timestamp)}</span>
|
||||
<span className="log-arrow-recv">←</span>
|
||||
<span className={cls}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandForms({ onSend }: { onSend: (payload: object) => void }) {
|
||||
const [warpUser, setWarpUser] = useState('');
|
||||
const [cmd, setCmd] = useState('');
|
||||
|
||||
function sendWarp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!warpUser.trim()) return;
|
||||
onSend({ type: 'instruction', instruction: 'warp', user: warpUser.trim() });
|
||||
setWarpUser('');
|
||||
}
|
||||
|
||||
function sendCmd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!cmd.trim()) return;
|
||||
onSend({ type: 'command', cmd: cmd.trim() });
|
||||
setCmd('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="command-panel">
|
||||
<form onSubmit={sendWarp} className="flex items-center gap-2">
|
||||
<span className="command-label">Warp</span>
|
||||
<input
|
||||
value={warpUser}
|
||||
onChange={e => setWarpUser(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="cmd-input"
|
||||
/>
|
||||
<button type="submit" className="cmd-btn cmd-btn-blue">Warp</button>
|
||||
</form>
|
||||
|
||||
<form onSubmit={sendCmd} className="flex items-center gap-2">
|
||||
<span className="command-label">Cmd</span>
|
||||
<input
|
||||
value={cmd}
|
||||
onChange={e => setCmd(e.target.value)}
|
||||
placeholder="Any command string…"
|
||||
className="cmd-input"
|
||||
/>
|
||||
<button type="submit" className="cmd-btn cmd-btn-green">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BotModalProps {
|
||||
initial?: BotConfig;
|
||||
onSave: (data: { name: string; url: string; token: string }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function BotModal({ initial, onSave, onClose }: BotModalProps) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [url, setUrl] = useState(initial?.url ?? '');
|
||||
const [token, setToken] = useState(initial?.token ?? '');
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (name.trim() && url.trim() && token.trim()) {
|
||||
onSave({ name: name.trim(), url: url.trim(), token: token.trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onClick={e => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="modal-box">
|
||||
<h2 className="modal-title">{initial ? 'Edit Bot' : 'Add Bot'}</h2>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="modal-label">Name</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="My bot" required className="modal-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="modal-label">WebSocket URL</label>
|
||||
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="ws://host:port" required className="modal-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="modal-label">Token</label>
|
||||
<input value={token} onChange={e => setToken(e.target.value)} placeholder="Authorization token" required className="modal-input" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onClick={onClose} className="modal-cancel-btn">Cancel</button>
|
||||
<button type="submit" className="modal-save-btn">
|
||||
{initial ? 'Save changes' : 'Add Bot'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Modal = { mode: 'add' } | { mode: 'edit'; bot: BotConfig };
|
||||
|
||||
export function App() {
|
||||
const [bots, setBots] = useState<BotConfig[]>([]);
|
||||
const [statuses, setStatuses] = useState<Record<number, BotStatus>>({});
|
||||
const [logs, setLogs] = useState<Record<number, LogEntry[]>>({});
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
|
||||
const [modal, setModal] = useState<Modal | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const appendLog = useCallback((botId: number, entry: LogEntry) => {
|
||||
setLogs(prev => {
|
||||
const prev_ = prev[botId] ?? [];
|
||||
return { ...prev, [botId]: [...prev_.slice(-499), entry] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
function connect() {
|
||||
if (!active) return;
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data as string) as S2CMessage;
|
||||
switch (msg.type) {
|
||||
case 'init':
|
||||
setBots(msg.bots);
|
||||
setStatuses(msg.statuses as unknown as Record<number, BotStatus>);
|
||||
break;
|
||||
case 'bot_status':
|
||||
setStatuses(prev => ({
|
||||
...prev,
|
||||
[msg.botId]: { wsStatus: msg.wsStatus, mcStatus: msg.mcStatus },
|
||||
}));
|
||||
break;
|
||||
case 'bot_message':
|
||||
appendLog(msg.botId, { direction: msg.direction, content: msg.content, timestamp: msg.timestamp });
|
||||
break;
|
||||
case 'bot_added':
|
||||
setBots(prev => [...prev, msg.bot]);
|
||||
break;
|
||||
case 'bot_updated':
|
||||
setBots(prev => prev.map(b => b.id === msg.bot.id ? msg.bot : b));
|
||||
break;
|
||||
case 'bot_deleted':
|
||||
setBots(prev => prev.filter(b => b.id !== msg.botId));
|
||||
setSelectedId(prev => prev === msg.botId ? null : prev);
|
||||
setCheckedIds(prev => { const s = new Set(prev); s.delete(msg.botId); return s; });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => { if (active) setTimeout(connect, 2000); };
|
||||
}
|
||||
|
||||
connect();
|
||||
return () => { active = false; wsRef.current?.close(); };
|
||||
}, [appendLog]);
|
||||
|
||||
function send(botIds: number[], payload: object) {
|
||||
wsRef.current?.send(JSON.stringify({ type: 'send', botIds, payload }));
|
||||
}
|
||||
|
||||
async function addBot(data: { name: string; url: string; token: string }) {
|
||||
await fetch('/api/bots', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
async function editBot(id: number, data: { name: string; url: string; token: string }) {
|
||||
await fetch(`/api/bots/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
async function deleteBot(id: number) {
|
||||
if (!confirm('Delete this bot?')) return;
|
||||
await fetch(`/api/bots/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
function toggleCheck(id: number, e: React.ChangeEvent<HTMLInputElement>) {
|
||||
e.stopPropagation();
|
||||
setCheckedIds(prev => {
|
||||
const s = new Set(prev);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
const isBulkMode = checkedIds.size > 0;
|
||||
const selectedBot = bots.find(b => b.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen text-sm overflow-hidden">
|
||||
{/* ── sidebar ── */}
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<span className="sidebar-title">Bot Manager</span>
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'add' })}
|
||||
title="Add bot"
|
||||
className="sidebar-add-btn"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
{bots.length > 0 && (
|
||||
<div className="select-bar">
|
||||
<button onClick={() => setCheckedIds(new Set(bots.map(b => b.id)))}>
|
||||
Select all
|
||||
</button>
|
||||
{isBulkMode && (
|
||||
<button onClick={() => setCheckedIds(new Set())}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{bots.length === 0 && (
|
||||
<p className="p-4 text-xs italic" style={{ color: 'rgba(255,255,255,0.2)' }}>
|
||||
No bots. Click + to add one.
|
||||
</p>
|
||||
)}
|
||||
{bots.map(bot => {
|
||||
const s = statuses[bot.id];
|
||||
const isSelected = selectedId === bot.id && !isBulkMode;
|
||||
const isChecked = checkedIds.has(bot.id);
|
||||
return (
|
||||
<div
|
||||
key={bot.id}
|
||||
onClick={() => setSelectedId(bot.id)}
|
||||
className={`bot-item group ${isSelected ? 'selected' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={e => toggleCheck(bot.id, e)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bot-name">{bot.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Dot status={s?.wsStatus ?? 'disconnected'} title="WS" />
|
||||
<span className="text-xs" style={{ color: 'rgba(255,255,255,0.2)' }}>WS</span>
|
||||
<Dot status={s?.mcStatus ?? 'unknown'} title="MC" />
|
||||
<span className="text-xs" style={{ color: 'rgba(255,255,255,0.2)' }}>MC</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-actions">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setModal({ mode: 'edit', bot }); }}
|
||||
className="bot-action-btn"
|
||||
title="Edit"
|
||||
>✎</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); deleteBot(bot.id); }}
|
||||
className="bot-action-btn delete"
|
||||
title="Delete"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── main panel ── */}
|
||||
<main className="main-panel">
|
||||
{isBulkMode ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">Bulk Actions</span>
|
||||
<span className="text-xs" style={{ color: 'rgba(255,255,255,0.3)' }}>
|
||||
{checkedIds.size} bot{checkedIds.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 ml-1">
|
||||
{bots.filter(b => checkedIds.has(b.id)).map(b => (
|
||||
<span key={b.id} className="bot-badge">{b.name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CommandForms onSend={payload => send([...checkedIds], payload)} />
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
) : selectedBot ? (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">{selectedBot.name}</span>
|
||||
<div className="flex items-center gap-4 text-xs" style={{ color: 'rgba(255,255,255,0.35)' }}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Dot status={statuses[selectedBot.id]?.wsStatus ?? 'disconnected'} title="WS" />
|
||||
WS: {statuses[selectedBot.id]?.wsStatus ?? 'disconnected'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Dot status={statuses[selectedBot.id]?.mcStatus ?? 'unknown'} title="MC" />
|
||||
MC: {statuses[selectedBot.id]?.mcStatus ?? 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<MessageLog entries={logs[selectedBot.id] ?? []} />
|
||||
<CommandForms onSend={payload => send([selectedBot.id], payload)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
Select a bot to manage it, or check multiple for bulk actions.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* ── modals ── */}
|
||||
{modal?.mode === 'add' && (
|
||||
<BotModal onSave={addBot} onClose={() => setModal(null)} />
|
||||
)}
|
||||
{modal?.mode === 'edit' && (
|
||||
<BotModal initial={modal.bot} onSave={data => editBot(modal.bot.id, data)} onClose={() => setModal(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { BotConfig, WsStatus, McStatus } from "./types";
|
||||
|
||||
const MIN_RETRY = 1_000;
|
||||
const MAX_RETRY = 30_000;
|
||||
|
||||
interface BotConn {
|
||||
config: BotConfig;
|
||||
ws: WebSocket | null;
|
||||
wsStatus: WsStatus;
|
||||
mcStatus: McStatus;
|
||||
retryTimer: ReturnType<typeof setTimeout> | null;
|
||||
retryDelay: number;
|
||||
destroyed: boolean;
|
||||
}
|
||||
|
||||
type MessageCb = (botId: number, direction: 'sent' | 'received', content: string) => void;
|
||||
type StatusCb = (botId: number, wsStatus: WsStatus, mcStatus: McStatus) => void;
|
||||
|
||||
class BotManager {
|
||||
private conns = new Map<number, BotConn>();
|
||||
private messageCbs = new Set<MessageCb>();
|
||||
private statusCbs = new Set<StatusCb>();
|
||||
|
||||
add(config: BotConfig) {
|
||||
if (this.conns.has(config.id)) this.remove(config.id);
|
||||
const conn: BotConn = {
|
||||
config,
|
||||
ws: null,
|
||||
wsStatus: 'disconnected',
|
||||
mcStatus: 'unknown',
|
||||
retryTimer: null,
|
||||
retryDelay: MIN_RETRY,
|
||||
destroyed: false,
|
||||
};
|
||||
this.conns.set(config.id, conn);
|
||||
this.connect(config.id);
|
||||
}
|
||||
|
||||
remove(id: number) {
|
||||
const conn = this.conns.get(id);
|
||||
if (!conn) return;
|
||||
conn.destroyed = true;
|
||||
if (conn.retryTimer) clearTimeout(conn.retryTimer);
|
||||
conn.ws?.close();
|
||||
this.conns.delete(id);
|
||||
}
|
||||
|
||||
update(config: BotConfig) {
|
||||
this.remove(config.id);
|
||||
this.add(config);
|
||||
}
|
||||
|
||||
send(id: number, payload: object): boolean {
|
||||
const conn = this.conns.get(id);
|
||||
if (!conn?.ws || conn.ws.readyState !== WebSocket.OPEN) return false;
|
||||
const msg = JSON.stringify(payload);
|
||||
conn.ws.send(msg);
|
||||
this.emitMessage(id, 'sent', msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
getAllStatuses(): Record<string, { wsStatus: WsStatus; mcStatus: McStatus }> {
|
||||
const out: Record<string, { wsStatus: WsStatus; mcStatus: McStatus }> = {};
|
||||
for (const [id, conn] of this.conns) {
|
||||
out[id] = { wsStatus: conn.wsStatus, mcStatus: conn.mcStatus };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
onMessage(cb: MessageCb) { this.messageCbs.add(cb); return () => this.messageCbs.delete(cb); }
|
||||
onStatus(cb: StatusCb) { this.statusCbs.add(cb); return () => this.statusCbs.delete(cb); }
|
||||
|
||||
private connect(id: number) {
|
||||
const conn = this.conns.get(id);
|
||||
if (!conn || conn.destroyed) return;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(conn.config.url, {
|
||||
headers: { Authorization: conn.config.token },
|
||||
} as unknown as string);
|
||||
conn.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (conn.destroyed) return void ws.close();
|
||||
conn.wsStatus = 'connected';
|
||||
conn.retryDelay = MIN_RETRY;
|
||||
this.emitStatus(id);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = String(event.data);
|
||||
this.emitMessage(id, 'received', data);
|
||||
if (data === 'bot.connected') {
|
||||
conn.mcStatus = 'connected';
|
||||
this.emitStatus(id);
|
||||
} else if (data === 'bot.reconnecting') {
|
||||
conn.mcStatus = 'reconnecting';
|
||||
this.emitStatus(id);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (conn.destroyed) return;
|
||||
conn.ws = null;
|
||||
conn.wsStatus = 'reconnecting';
|
||||
conn.mcStatus = 'unknown';
|
||||
this.emitStatus(id);
|
||||
conn.retryTimer = setTimeout(() => this.connect(id), conn.retryDelay);
|
||||
conn.retryDelay = Math.min(conn.retryDelay * 2, MAX_RETRY);
|
||||
};
|
||||
} catch {
|
||||
if (conn.destroyed) return;
|
||||
conn.wsStatus = 'reconnecting';
|
||||
this.emitStatus(id);
|
||||
conn.retryTimer = setTimeout(() => this.connect(id), conn.retryDelay);
|
||||
conn.retryDelay = Math.min(conn.retryDelay * 2, MAX_RETRY);
|
||||
}
|
||||
}
|
||||
|
||||
private emitMessage(botId: number, direction: 'sent' | 'received', content: string) {
|
||||
for (const cb of this.messageCbs) cb(botId, direction, content);
|
||||
}
|
||||
|
||||
private emitStatus(botId: number) {
|
||||
const conn = this.conns.get(botId);
|
||||
if (!conn) return;
|
||||
for (const cb of this.statusCbs) cb(botId, conn.wsStatus, conn.mcStatus);
|
||||
}
|
||||
}
|
||||
|
||||
export const botManager = new BotManager();
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync } from "fs";
|
||||
import type { BotConfig } from "./types";
|
||||
|
||||
mkdirSync("data", { recursive: true });
|
||||
const db = new Database("data/bots.db");
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS bots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
token TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
export function getBots(): BotConfig[] {
|
||||
return db.query("SELECT * FROM bots").all() as BotConfig[];
|
||||
}
|
||||
|
||||
export function addBot(name: string, url: string, token: string): BotConfig {
|
||||
return db.query(
|
||||
"INSERT INTO bots (name, url, token) VALUES (?, ?, ?) RETURNING *"
|
||||
).get(name, url, token) as BotConfig;
|
||||
}
|
||||
|
||||
export function updateBot(id: number, name: string, url: string, token: string): BotConfig | null {
|
||||
return db.query(
|
||||
"UPDATE bots SET name=?, url=?, token=? WHERE id=? RETURNING *"
|
||||
).get(name, url, token, id) as BotConfig | null;
|
||||
}
|
||||
|
||||
export function deleteBot(id: number): void {
|
||||
db.run("DELETE FROM bots WHERE id=?", [id]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* This file is the entry point for the React app, it sets up the root
|
||||
* element and renders the App component to the DOM.
|
||||
*
|
||||
* It is included in `src/index.html`.
|
||||
*/
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
|
||||
function start() {
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<App />);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@apply text-[rgba(255,255,255,0.87)] bg-[#242424] font-sans;
|
||||
--color-border: rgba(255, 255, 255, 0.07);
|
||||
--color-surface: #1e1e1e;
|
||||
--color-surface-raised: #252525;
|
||||
--color-surface-hover: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 h-screen overflow-hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
/* scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.12) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 2px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* sidebar */
|
||||
.sidebar {
|
||||
@apply w-60 flex-shrink-0 flex flex-col;
|
||||
background: #1a1a1a;
|
||||
border-right: 1px solid rgba(255,255,255,0.07);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@apply px-4 py-3 flex items-center justify-between;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
@apply font-semibold text-sm tracking-wide;
|
||||
color: rgba(255,255,255,0.9);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sidebar-add-btn {
|
||||
@apply w-6 h-6 flex items-center justify-center rounded text-lg leading-none;
|
||||
color: #60a5fa;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.sidebar-add-btn:hover {
|
||||
background: rgba(96,165,250,0.12);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
/* bot list items */
|
||||
.bot-item {
|
||||
@apply flex items-center gap-2 px-3 py-2.5 cursor-pointer;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.bot-item:hover {
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
.bot-item.selected {
|
||||
background: rgba(59,130,246,0.1);
|
||||
}
|
||||
.bot-item.selected .bot-name {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
@apply truncate text-sm font-medium;
|
||||
color: rgba(255,255,255,0.85);
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.bot-actions {
|
||||
@apply hidden items-center gap-0.5;
|
||||
}
|
||||
.bot-item:hover .bot-actions {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.bot-action-btn {
|
||||
@apply p-1 rounded text-xs;
|
||||
color: rgba(255,255,255,0.3);
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
.bot-action-btn:hover {
|
||||
color: rgba(255,255,255,0.85);
|
||||
background: rgba(255,255,255,0.07);
|
||||
}
|
||||
.bot-action-btn.delete:hover {
|
||||
color: #f87171;
|
||||
background: rgba(248,113,113,0.1);
|
||||
}
|
||||
|
||||
/* status dots */
|
||||
.status-dot {
|
||||
@apply inline-block w-2 h-2 rounded-full flex-shrink-0;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.3);
|
||||
}
|
||||
.status-dot.connected {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 4px rgba(34,197,94,0.4);
|
||||
}
|
||||
.status-dot.reconnecting {
|
||||
background: #facc15;
|
||||
box-shadow: 0 0 4px rgba(250,204,21,0.4);
|
||||
}
|
||||
.status-dot.disconnected {
|
||||
background: #ef4444;
|
||||
}
|
||||
.status-dot.unknown {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* panel header */
|
||||
.panel-header {
|
||||
@apply px-4 py-3 flex items-center gap-3 flex-shrink-0 text-sm;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
background: rgba(255,255,255,0.01);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply font-semibold;
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
/* message log */
|
||||
.message-log {
|
||||
@apply flex-1 overflow-y-auto p-3 space-y-0.5;
|
||||
font-family: "SF Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
@apply flex gap-2 px-1 py-px rounded;
|
||||
transition: background 0.08s;
|
||||
}
|
||||
.log-row:hover {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: rgba(255,255,255,0.2);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.log-arrow-sent {
|
||||
color: #3b82f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-arrow-recv {
|
||||
color: rgba(255,255,255,0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* command forms */
|
||||
.command-panel {
|
||||
@apply p-4 space-y-2.5 flex-shrink-0;
|
||||
border-top: 1px solid rgba(255,255,255,0.07);
|
||||
background: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.command-label {
|
||||
@apply text-xs uppercase tracking-wider w-10 flex-shrink-0;
|
||||
color: rgba(255,255,255,0.2);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.cmd-input {
|
||||
@apply flex-1 text-sm px-3 py-1.5 rounded;
|
||||
background: rgba(0,0,0,0.25);
|
||||
border: 1px solid rgba(255,255,255,0.09);
|
||||
color: rgba(255,255,255,0.9);
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.cmd-input::placeholder {
|
||||
color: rgba(255,255,255,0.2);
|
||||
}
|
||||
.cmd-input:focus {
|
||||
border-color: rgba(59,130,246,0.5);
|
||||
}
|
||||
|
||||
.cmd-btn {
|
||||
@apply px-4 py-1.5 text-sm rounded font-medium flex-shrink-0 text-white;
|
||||
transition: opacity 0.12s, filter 0.12s;
|
||||
}
|
||||
.cmd-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.cmd-btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cmd-btn-blue {
|
||||
background: #2563eb;
|
||||
}
|
||||
.cmd-btn-green {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
/* modal */
|
||||
.modal-backdrop {
|
||||
@apply fixed inset-0 flex items-center justify-center z-50;
|
||||
background: rgba(0,0,0,0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: fade-in 0.12s ease;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
@apply w-full max-w-md rounded-xl p-6 shadow-2xl;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid rgba(255,255,255,0.09);
|
||||
animation: slide-up 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@apply text-sm font-semibold mb-5;
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
@apply text-xs uppercase tracking-wider block mb-1.5;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
@apply w-full text-sm px-3 py-2 rounded-lg;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,255,255,0.09);
|
||||
color: rgba(255,255,255,0.9);
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.modal-input::placeholder {
|
||||
color: rgba(255,255,255,0.18);
|
||||
}
|
||||
.modal-input:focus {
|
||||
border-color: rgba(59,130,246,0.55);
|
||||
}
|
||||
|
||||
.modal-cancel-btn {
|
||||
@apply px-4 py-2 text-sm rounded-lg;
|
||||
color: rgba(255,255,255,0.35);
|
||||
transition: color 0.1s, background 0.1s;
|
||||
}
|
||||
.modal-cancel-btn:hover {
|
||||
color: rgba(255,255,255,0.7);
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.modal-save-btn {
|
||||
@apply px-4 py-2 text-sm rounded-lg font-medium text-white;
|
||||
background: #2563eb;
|
||||
transition: filter 0.12s;
|
||||
}
|
||||
.modal-save-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* select-all bar */
|
||||
.select-bar {
|
||||
@apply px-3 py-1.5 flex gap-3 text-xs;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.select-bar button {
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.select-bar button:hover {
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
/* bulk badge */
|
||||
.bot-badge {
|
||||
@apply px-2 py-0.5 rounded text-xs;
|
||||
background: rgba(255,255,255,0.07);
|
||||
color: rgba(255,255,255,0.55);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
/* empty state */
|
||||
.empty-state {
|
||||
@apply flex-1 flex items-center justify-center text-sm;
|
||||
color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* main panel bg */
|
||||
.main-panel {
|
||||
@apply flex-1 flex flex-col overflow-hidden;
|
||||
background: #1c1c1c;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(6px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
|
||||
<title>Bot Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { serve, type ServerWebSocket } from "bun";
|
||||
import index from "./index.html";
|
||||
import { getBots, addBot, updateBot, deleteBot } from "./db";
|
||||
import { botManager } from "./bot-manager";
|
||||
import type { S2CMessage, C2SMessage } from "./types";
|
||||
|
||||
for (const bot of getBots()) {
|
||||
botManager.add(bot);
|
||||
}
|
||||
|
||||
const clients = new Set<ServerWebSocket<unknown>>();
|
||||
|
||||
function broadcast(msg: S2CMessage) {
|
||||
const raw = JSON.stringify(msg);
|
||||
for (const ws of clients) ws.send(raw);
|
||||
}
|
||||
|
||||
botManager.onStatus((botId, wsStatus, mcStatus) => {
|
||||
broadcast({ type: 'bot_status', botId, wsStatus, mcStatus });
|
||||
});
|
||||
|
||||
botManager.onMessage((botId, direction, content) => {
|
||||
broadcast({ type: 'bot_message', botId, direction, content, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
const server = serve({
|
||||
routes: {
|
||||
"/ws": (req, server) => {
|
||||
if (server.upgrade(req)) return;
|
||||
return new Response("Upgrade failed", { status: 400 });
|
||||
},
|
||||
|
||||
"/api/bots": {
|
||||
GET() {
|
||||
return Response.json(getBots());
|
||||
},
|
||||
async POST(req) {
|
||||
const { name, url, token } = await req.json() as { name: string; url: string; token: string };
|
||||
const bot = addBot(name, url, token);
|
||||
botManager.add(bot);
|
||||
broadcast({ type: 'bot_added', bot });
|
||||
return Response.json(bot, { status: 201 });
|
||||
},
|
||||
},
|
||||
|
||||
"/api/bots/:id": {
|
||||
async PUT(req) {
|
||||
const id = Number(req.params.id);
|
||||
const { name, url, token } = await req.json() as { name: string; url: string; token: string };
|
||||
const bot = updateBot(id, name, url, token);
|
||||
if (!bot) return new Response("Not found", { status: 404 });
|
||||
botManager.update(bot);
|
||||
broadcast({ type: 'bot_updated', bot });
|
||||
return Response.json(bot);
|
||||
},
|
||||
DELETE(req) {
|
||||
const id = Number(req.params.id);
|
||||
deleteBot(id);
|
||||
botManager.remove(id);
|
||||
broadcast({ type: 'bot_deleted', botId: id });
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
},
|
||||
|
||||
"/*": index,
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws) {
|
||||
clients.add(ws);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'init',
|
||||
bots: getBots(),
|
||||
statuses: botManager.getAllStatuses(),
|
||||
} satisfies S2CMessage));
|
||||
},
|
||||
message(_ws, raw) {
|
||||
try {
|
||||
const msg = JSON.parse(String(raw)) as C2SMessage;
|
||||
if (msg.type === 'send') {
|
||||
for (const botId of msg.botIds) {
|
||||
botManager.send(botId, msg.payload);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
close(ws) {
|
||||
clients.delete(ws);
|
||||
},
|
||||
},
|
||||
|
||||
development: process.env.NODE_ENV !== "production" && {
|
||||
hmr: true,
|
||||
console: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`🚀 Server running at ${server.url}`);
|
||||
@@ -0,0 +1 @@
|
||||
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
|
||||
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
|
||||
<g stroke="#61dafb" stroke-width="1" fill="none">
|
||||
<ellipse rx="11" ry="4.2"/>
|
||||
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
|
||||
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
@@ -0,0 +1,27 @@
|
||||
export interface BotConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type WsStatus = 'connected' | 'disconnected' | 'reconnecting';
|
||||
export type McStatus = 'connected' | 'reconnecting' | 'unknown';
|
||||
|
||||
export interface BotStatus {
|
||||
wsStatus: WsStatus;
|
||||
mcStatus: McStatus;
|
||||
}
|
||||
|
||||
// Backend → Frontend
|
||||
export type S2CMessage =
|
||||
| { type: 'init'; bots: BotConfig[]; statuses: Record<string, BotStatus> }
|
||||
| { type: 'bot_status'; botId: number; wsStatus: WsStatus; mcStatus: McStatus }
|
||||
| { type: 'bot_message'; botId: number; direction: 'sent' | 'received'; content: string; timestamp: number }
|
||||
| { type: 'bot_added'; bot: BotConfig }
|
||||
| { type: 'bot_updated'; bot: BotConfig }
|
||||
| { type: 'bot_deleted'; botId: number };
|
||||
|
||||
// Frontend → Backend
|
||||
export type C2SMessage =
|
||||
| { type: 'send'; botIds: number[]; payload: object };
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user