Initial Commit

This commit is contained in:
2026-08-20 02:27:36 +02:00
commit eb006ab895
25 changed files with 1675 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(bun x *)"
]
}
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
data
.env*
*.db
.git
.claude
+14
View File
@@ -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
View File
@@ -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
+106
View File
@@ -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
View File
@@ -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"]
+21
View File
@@ -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.
+149
View File
@@ -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`);
+17
View File
@@ -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;
}
+79
View File
@@ -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=="],
}
}
+4
View File
@@ -0,0 +1,4 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"
+24
View File
@@ -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:
+22
View File
@@ -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"
}
}
+63
View File
@@ -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
View File
@@ -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;
+133
View File
@@ -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();
+35
View File
@@ -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]);
}
+21
View File
@@ -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
View File
@@ -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; }
}
+13
View File
@@ -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>
+98
View File
@@ -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}`);
+1
View File
@@ -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

+8
View File
@@ -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

+27
View File
@@ -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 };
+35
View File
@@ -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"]
}