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