dokumentation 12 seiten

This commit is contained in:
Hikyu
2026-07-07 16:59:54 +02:00
parent 13154fbb53
commit 3a09a7285e
68 changed files with 7690 additions and 279 deletions
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# 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`.
+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;
}
+110
View File
@@ -0,0 +1,110 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"arangojs": "^9.2.0",
"bun-plugin-tailwind": "^0.1.2",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",
"lucide-react": "^1.23.0",
"memjs": "^1.3.2",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.11",
},
"devDependencies": {
"@types/bun": "latest",
"@types/memjs": "^1.3.3",
"@types/react": "^19",
"@types/react-dom": "^19",
},
},
},
"packages": {
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="],
"@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="],
"@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="],
"@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="],
"@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="],
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/memjs": ["@types/memjs@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-OqZuZ2T0ErrygA/WUqQjmsB1GpxAt84OCgKPKbsSWwYKA/oe/lTiqvqMgFOv/Ngl8p+nytgdhrdxJtikOgimCg=="],
"@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"arangojs": ["arangojs@9.3.0", "", { "dependencies": { "@types/node": "^20.11.26" } }, "sha512-+z/TxumH8ywsXAN0oyQAxMnBMtDbd4tjFhGXzygAHD8+YDzEC705STLBh38KGbwxWeekalu2XEHUtrJ/NONgTQ=="],
"bun": ["bun@1.3.14", "", { "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": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"lucide-react": ["lucide-react@1.23.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw=="],
"memjs": ["memjs@1.3.2", "", {}, "sha512-qUEg2g8vxPe+zPn09KidjIStHPtoBO8Cttm8bgJFWWabbsjQ9Av9Ky+6UcvKx6ue0LLb/LEhtcyQpRyKfzeXcg=="],
"motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="],
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"arangojs/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
"arangojs/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}
+4
View File
@@ -0,0 +1,4 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"
+29
View File
@@ -0,0 +1,29 @@
{
"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": {
"arangojs": "^9.2.0",
"bun-plugin-tailwind": "^0.1.2",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",
"lucide-react": "^1.23.0",
"memjs": "^1.3.2",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest",
"@types/memjs": "^1.3.3"
}
}
+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>
);
}
+856
View File
@@ -0,0 +1,856 @@
import React, { useState, useEffect } from "react";
import "./index.css";
import {
Database, Home, Search, Search as SearchIcon, Map as MapIcon, Shield, Sword,
Heart, Activity, User, Box, ChevronDown, ChevronRight,
Zap, Crosshair, Droplets, Clock
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
function Ms({ ms, className }: { ms?: number, className?: string }) {
if (ms == null) return null;
return (
<span className={cn("inline-flex items-center gap-1 font-mono text-[10px] text-indigo-300/80 tabular-nums bg-indigo-500/10 border border-indigo-500/20 rounded px-1.5 py-0.5", className)}>
<Clock className="w-2.5 h-2.5" />
{ms.toFixed(3)} ms
</span>
);
}
type Player = { id: string, name: string, health: string, stamina: string, level: string, region_id: string };
type Enemy = { id: string, name: string, health: string, level: string, damage: string, itemsIds: string[], region_id: string, _ms?: number };
type Item = { id: string, name: string, gear_type?: string, gear_id?: string, stats?: Record<string, string>, _ms?: number };
type Region = { id: string, name: string, enemiesIds: string[], itemsIds: string[], enemies?: Enemy[], items?: Item[], _ms?: number };
type Timing = { op: string, key: string, ms: number };
type Timings = { count: number, total: number, ops: Timing[] };
export function App() {
const [activeTab, setActiveTab] = useState<"home" | "search">("home");
const [db, setDb] = useState("redis");
const [dbDropdownOpen, setDbDropdownOpen] = useState(false);
const [playerData, setPlayerData] = useState<{player: Player, region: Region} | null>(null);
const [universe, setUniverse] = useState<{regions: Region[], enemies: Enemy[], items: Item[]} | null>(null);
const [playerTimings, setPlayerTimings] = useState<Timings | null>(null);
const [universeTimings, setUniverseTimings] = useState<Timings | null>(null);
// Modals
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
const [selectedEnemy, setSelectedEnemy] = useState<Enemy | null>(null);
useEffect(() => {
setUniverse(null);
setUniverseTimings(null);
fetch(`/api/universe?db=${db}`)
.then(r => r.json())
.then(data => {
setUniverse(data);
setUniverseTimings(data._timings || null);
})
.catch(console.error);
}, [db]);
useEffect(() => {
setPlayerData(null);
setPlayerTimings(null);
const fetchPlayer = async () => {
try {
const res = await fetch(`/api/player?db=${db}`);
if (res.ok) {
const data = await res.json();
setPlayerData(data);
setPlayerTimings(data._timings || null);
}
} catch (e) {
console.error(e);
}
};
fetchPlayer();
const int = setInterval(fetchPlayer, 1500);
return () => clearInterval(int);
}, [db]);
const findItemRegions = (itemId: string) => {
if (!universe) return [];
return universe.regions.filter(r => r.itemsIds?.includes(itemId));
};
const findItemEnemies = (itemId: string) => {
if (!universe) return [];
return universe.enemies.filter(e => e.itemsIds?.includes(itemId));
};
const dbOptions = [
{ id: "redis", name: "Redis" },
{ id: "memcached", name: "Memcached" },
{ id: "arangodb", name: "ArangoDB" }
];
const dbLabel = dbOptions.find(o => o.id === db)?.name || db;
return (
<div className="flex h-screen bg-slate-950 text-slate-200 overflow-hidden font-sans selection:bg-indigo-500/30">
{/* Sidebar */}
<aside className="w-64 border-r border-white/5 bg-slate-900/50 backdrop-blur-xl flex flex-col relative z-20">
<div className="p-6 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
<Shield className="w-5 h-5 text-white" />
</div>
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-indigo-200 to-purple-200">
Ringstats
</h1>
</div>
<nav className="flex-1 px-4 py-4 space-y-2">
<button
onClick={() => setActiveTab("home")}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300",
activeTab === "home"
? "bg-white/10 text-white shadow-sm border border-white/10"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
)}
>
<Home className="w-5 h-5" />
<span className="font-medium">Home</span>
</button>
<button
onClick={() => setActiveTab("search")}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300",
activeTab === "search"
? "bg-white/10 text-white shadow-sm border border-white/10"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
)}
>
<Search className="w-5 h-5" />
<span className="font-medium">Search</span>
</button>
</nav>
<div className="p-4 border-t border-white/5">
<div className="text-xs text-slate-500 text-center">Live Sync Active</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col relative">
{/* Topbar */}
<header className="h-20 border-b border-white/5 bg-slate-950/50 backdrop-blur-md flex items-center justify-between px-8 z-10">
<div className="flex items-center gap-4">
<div className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
<span className="text-sm font-medium text-emerald-400">Connected to Player</span>
</div>
<div className="relative">
<button
onClick={() => setDbDropdownOpen(!dbDropdownOpen)}
className="flex items-center gap-3 px-4 py-2.5 rounded-xl bg-slate-900 border border-white/10 hover:border-white/20 transition-all text-sm font-medium"
>
<Database className="w-4 h-4 text-indigo-400" />
<span>{dbOptions.find(o => o.id === db)?.name}</span>
<ChevronDown className="w-4 h-4 text-slate-400" />
</button>
<AnimatePresence>
{dbDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute right-0 mt-2 w-48 rounded-xl bg-slate-800 border border-white/10 shadow-xl overflow-hidden z-50"
>
{dbOptions.map((option) => (
<button
key={option.id}
onClick={() => {
setDb(option.id);
setDbDropdownOpen(false);
}}
className={cn(
"w-full text-left px-4 py-3 text-sm transition-colors",
db === option.id ? "bg-indigo-500/10 text-indigo-300" : "hover:bg-white/5 text-slate-300"
)}
>
{option.name}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</header>
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-8 relative">
{/* Background Glow */}
<div className="absolute top-0 left-1/4 w-96 h-96 bg-indigo-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-[120px] pointer-events-none" />
<AnimatePresence mode="wait">
{activeTab === "home" ? (
<HomeView key="home" data={playerData} timings={playerTimings} dbLabel={dbLabel} onItemClick={setSelectedItem} onRegionClick={setSelectedRegion} />
) : (
<SearchView key="search" universe={universe} timings={universeTimings} dbLabel={dbLabel} onItemClick={setSelectedItem} onRegionClick={setSelectedRegion} />
)}
</AnimatePresence>
</div>
</main>
{/* Item Details Modal */}
<AnimatePresence>
{selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden shadow-2xl"
>
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-white/[0.02] shrink-0">
<div className="flex items-center gap-3">
<Box className="w-5 h-5 text-indigo-400" />
<h3 className="text-lg font-bold text-white">{selectedItem.name}</h3>
<Ms ms={selectedItem._ms} />
</div>
<button onClick={() => setSelectedItem(null)} className="text-slate-400 hover:text-white transition-colors">
✕
</button>
</div>
<div className="p-6 space-y-6 overflow-y-auto">
{selectedItem.stats && Object.keys(selectedItem.stats).length > 0 && (
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Item Stats</h4>
<div className="grid grid-cols-2 gap-3">
{Object.entries(selectedItem.stats).map(([key, value]) => (
<div key={key} className="bg-white/5 p-3 rounded-lg border border-white/5 flex flex-col">
<span className="text-xs text-slate-500 capitalize">{key.replace("_", " ")}</span>
<span className="text-sm font-bold text-white">{String(value)}</span>
</div>
))}
</div>
</div>
)}
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Found In Regions</h4>
<div className="space-y-2">
{findItemRegions(selectedItem.id).length > 0 ? (
findItemRegions(selectedItem.id).map(r => (
<button
key={r.id}
onClick={() => { setSelectedItem(null); setSelectedRegion(r); }}
className="w-full text-left flex items-center gap-3 p-3 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<MapIcon className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium">{r.name}</span>
</button>
))
) : (
<div className="text-sm text-slate-500 italic p-3 text-center border border-white/5 rounded-lg border-dashed">
No known regions for this item.
</div>
)}
</div>
</div>
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Dropped By</h4>
<div className="space-y-2">
{findItemEnemies(selectedItem.id).length > 0 ? (
findItemEnemies(selectedItem.id).map(e => (
<button
key={e.id}
onClick={() => { setSelectedItem(null); setSelectedEnemy(e); }}
className="w-full text-left flex items-center gap-3 p-3 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<Crosshair className="w-4 h-4 text-rose-400" />
<span className="text-sm font-medium">{e.name}</span>
</button>
))
) : (
<div className="text-sm text-slate-500 italic p-3 text-center border border-white/5 rounded-lg border-dashed">
Not dropped by any specific enemy.
</div>
)}
</div>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Enemy Details Modal */}
<AnimatePresence>
{selectedEnemy && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md max-h-[90vh] flex flex-col overflow-hidden shadow-2xl"
>
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-white/[0.02] shrink-0">
<div className="flex items-center gap-3">
<Sword className="w-5 h-5 text-rose-400" />
<h3 className="text-lg font-bold text-white">{selectedEnemy.name}</h3>
<Ms ms={selectedEnemy._ms} />
</div>
<button onClick={() => setSelectedEnemy(null)} className="text-slate-400 hover:text-white transition-colors">
✕
</button>
</div>
<div className="p-6 space-y-6 overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 p-4 rounded-xl border border-white/5">
<div className="text-xs text-slate-500 mb-1">Health</div>
<div className="font-bold text-white flex items-center gap-2">
<Heart className="w-4 h-4 text-rose-500" />
{selectedEnemy.health}
</div>
</div>
<div className="bg-white/5 p-4 rounded-xl border border-white/5">
<div className="text-xs text-slate-500 mb-1">Damage Output</div>
<div className="font-bold text-white flex items-center gap-2">
<Activity className="w-4 h-4 text-orange-500" />
{selectedEnemy.damage}
</div>
</div>
</div>
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Location</h4>
{universe?.regions.find(r => r.id === selectedEnemy.region_id) ? (
<button
onClick={() => {
const r = universe.regions.find(r => r.id === selectedEnemy.region_id);
if(r) {
setSelectedEnemy(null);
setSelectedRegion(r);
}
}}
className="w-full text-left flex items-center gap-3 p-3 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<MapIcon className="w-4 h-4 text-purple-400" />
<span className="text-sm font-medium">{universe.regions.find(r => r.id === selectedEnemy.region_id)?.name}</span>
</button>
) : (
<div className="text-sm text-slate-500 italic p-3 text-center border border-white/5 rounded-lg border-dashed">
Unknown location.
</div>
)}
</div>
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Possible Drops</h4>
<div className="space-y-2">
{selectedEnemy.itemsIds && selectedEnemy.itemsIds.length > 0 ? (
selectedEnemy.itemsIds.map(iId => {
const itemObj = universe?.items.find(i => i.id === iId);
return itemObj ? (
<button
key={iId}
onClick={() => { setSelectedEnemy(null); setSelectedItem(itemObj); }}
className="w-full text-left flex items-center gap-3 p-3 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 transition-colors"
>
<Droplets className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium">{itemObj.name}</span>
</button>
) : null;
})
) : (
<div className="text-sm text-slate-500 italic p-3 text-center border border-white/5 rounded-lg border-dashed">
No specific drops recorded.
</div>
)}
</div>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Region Details Modal */}
<AnimatePresence>
{selectedRegion && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-lg max-h-[80vh] overflow-hidden shadow-2xl flex flex-col"
>
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-white/[0.02] shrink-0">
<div className="flex items-center gap-3">
<MapIcon className="w-5 h-5 text-purple-400" />
<h3 className="text-lg font-bold text-white">{selectedRegion.name}</h3>
<Ms ms={selectedRegion._ms} />
</div>
<button onClick={() => setSelectedRegion(null)} className="text-slate-400 hover:text-white transition-colors">
✕
</button>
</div>
<div className="p-6 overflow-y-auto space-y-6">
{/* Region Enemies */}
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Enemies Found Here</h4>
<div className="flex flex-wrap gap-2">
{selectedRegion.enemiesIds && selectedRegion.enemiesIds.length > 0 ? (
selectedRegion.enemiesIds.map(eId => {
const enemyObj = universe?.enemies.find(e => e.id === eId);
return enemyObj ? (
<button
key={eId}
onClick={() => { setSelectedRegion(null); setSelectedEnemy(enemyObj); }}
className="px-3 py-1.5 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/20 text-rose-300 rounded-lg text-sm flex items-center gap-2 transition-colors"
>
<Crosshair className="w-3 h-3" />
{enemyObj.name}
</button>
) : null;
})
) : (
<span className="text-sm text-slate-500">None</span>
)}
</div>
</div>
{/* Region Items */}
<div>
<h4 className="text-sm text-slate-400 font-bold uppercase tracking-wider mb-3">Loot In Region</h4>
<div className="flex flex-wrap gap-2">
{selectedRegion.itemsIds && selectedRegion.itemsIds.length > 0 ? (
selectedRegion.itemsIds.map(iId => {
const itemObj = universe?.items.find(i => i.id === iId);
return itemObj ? (
<button
key={iId}
onClick={() => { setSelectedRegion(null); setSelectedItem(itemObj); }}
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 text-emerald-300 rounded-lg text-sm flex items-center gap-2 transition-colors"
>
<Box className="w-3 h-3" />
{itemObj.name}
</button>
) : null;
})
) : (
<span className="text-sm text-slate-500">None</span>
)}
</div>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}
function TimingPanel({ title, timings, regionCached }: { title: string, timings: Timings | null, regionCached?: boolean }) {
const [open, setOpen] = useState(false);
if (!timings) return null;
return (
<div className="bg-slate-900/60 border border-white/5 rounded-2xl overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between p-4 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center border border-indigo-500/20">
<Clock className="w-4 h-4 text-indigo-400" />
</div>
<span className="text-sm font-medium text-slate-300">{title}</span>
{regionCached && (
<span className="font-mono text-[10px] text-emerald-300/90 tabular-nums bg-emerald-500/10 border border-emerald-500/20 rounded px-1.5 py-0.5">
Region aus Cache
</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-slate-500 tabular-nums">{timings.count} Ops</span>
<span className="text-sm font-mono font-bold text-indigo-300 tabular-nums">{timings.total.toFixed(2)} ms</span>
<motion.div animate={{ rotate: open ? 180 : 0 }}>
<ChevronDown className="w-4 h-4 text-slate-500" />
</motion.div>
</div>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-white/5 bg-black/20"
>
<div className="max-h-72 overflow-y-auto divide-y divide-white/5">
{timings.ops.map((op, i) => (
<div key={i} className="flex items-center justify-between px-4 py-2 text-xs gap-4">
<span className="font-mono text-slate-400 truncate">
<span className="text-indigo-300/70">{op.op}</span> <span className="text-slate-500">{op.key}</span>
</span>
<span className="font-mono text-slate-300 tabular-nums shrink-0">{op.ms.toFixed(3)} ms</span>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function HomeView({ data, timings, dbLabel, onItemClick, onRegionClick }: { data: any, timings: Timings | null, dbLabel: string, onItemClick: (item: Item) => void, onRegionClick: (r: Region) => void }) {
if (!data) return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
</div>
);
const { player, region, regionCached } = data;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="max-w-5xl mx-auto space-y-8"
>
<TimingPanel title={`${dbLabel} · Live Sync (Player + Region)`} timings={timings} regionCached={regionCached} />
{/* Player Stats Card */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="col-span-1 md:col-span-2 bg-gradient-to-br from-slate-900 to-slate-800/80 p-8 rounded-3xl border border-white/10 relative overflow-hidden group">
<div className="absolute inset-0 bg-indigo-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="relative z-10 flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<User className="w-6 h-6 text-indigo-400" />
<h2 className="text-3xl font-bold text-white tracking-tight">{player.name}</h2>
</div>
<div className="text-indigo-300 font-medium">Level {player.level}</div>
</div>
<div className="text-right">
<div className="text-sm text-slate-400 mb-1">Player ID</div>
<div className="font-mono text-slate-300">#{player.id}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-8 relative z-10">
<div className="bg-black/20 rounded-2xl p-4 border border-white/5 backdrop-blur-sm">
<div className="flex items-center gap-2 text-rose-400 mb-2">
<Heart className="w-4 h-4" />
<span className="text-sm font-medium">Health</span>
</div>
<div className="text-2xl font-bold text-white">{player.health}</div>
<div className="w-full bg-slate-800 h-1.5 mt-3 rounded-full overflow-hidden">
<div className="bg-rose-500 h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, Math.max(0, parseInt(player.health)))}%` }} />
</div>
</div>
<div className="bg-black/20 rounded-2xl p-4 border border-white/5 backdrop-blur-sm">
<div className="flex items-center gap-2 text-emerald-400 mb-2">
<Zap className="w-4 h-4" />
<span className="text-sm font-medium">Stamina</span>
</div>
<div className="text-2xl font-bold text-white">{player.stamina}</div>
<div className="w-full bg-slate-800 h-1.5 mt-3 rounded-full overflow-hidden">
<div className="bg-emerald-500 h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, Math.max(0, parseInt(player.stamina)))}%` }} />
</div>
</div>
</div>
</div>
{/* Current Region Card */}
<button
onClick={() => region && onRegionClick(region)}
className="col-span-1 bg-slate-900 p-8 rounded-3xl border border-white/10 flex flex-col justify-center items-center text-center relative overflow-hidden group hover:bg-slate-800 hover:border-purple-500/50 transition-all text-left"
>
<MapIcon className="absolute -right-8 -bottom-8 w-48 h-48 text-white/5 group-hover:scale-110 group-hover:text-purple-500/10 transition-all duration-700" />
<div className="relative z-10">
<div className="w-12 h-12 bg-white/5 group-hover:bg-purple-500/20 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-white/10 group-hover:border-purple-500/30 transition-colors">
<MapIcon className="w-6 h-6 text-purple-400 group-hover:text-purple-300" />
</div>
<h3 className="text-sm text-slate-400 font-medium tracking-widest uppercase mb-2">Current Location</h3>
<div className="text-2xl font-bold text-white group-hover:text-purple-100 transition-colors">{region?.name || "Unknown"}</div>
{region?._ms != null && <div className="mt-2 flex justify-center"><Ms ms={region._ms} /></div>}
<div className="mt-3 text-xs text-purple-400 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
View Details <ChevronRight className="w-3 h-3" />
</div>
</div>
</button>
</div>
{region && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Enemies List */}
<div className="space-y-4">
<div className="flex items-center gap-3 px-2">
<Crosshair className="w-5 h-5 text-rose-400" />
<h3 className="text-xl font-bold">Enemies Nearby</h3>
<span className="ml-auto bg-rose-500/10 text-rose-400 py-1 px-3 rounded-full text-sm font-medium">
{region.enemies?.length || 0}
</span>
</div>
<div className="space-y-3">
{region.enemies?.length === 0 && (
<div className="p-6 text-center text-slate-500 border border-white/5 border-dashed rounded-2xl">
No enemies in this region.
</div>
)}
{region.enemies?.map((enemy: Enemy) => (
<EnemyCard key={enemy.id} enemy={enemy} onItemClick={onItemClick} allItems={region.items} allRegions={[region]} />
))}
</div>
</div>
{/* Items List */}
<div className="space-y-4">
<div className="flex items-center gap-3 px-2">
<Box className="w-5 h-5 text-emerald-400" />
<h3 className="text-xl font-bold">Loot Nearby</h3>
<span className="ml-auto bg-emerald-500/10 text-emerald-400 py-1 px-3 rounded-full text-sm font-medium">
{region.items?.length || 0}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
{region.items?.length === 0 && (
<div className="col-span-2 p-6 text-center text-slate-500 border border-white/5 border-dashed rounded-2xl">
No items in this region.
</div>
)}
{region.items?.map((item: Item) => (
<button
key={item.id}
onClick={() => onItemClick(item)}
className="flex items-center gap-3 p-4 bg-slate-900 border border-white/5 rounded-2xl hover:bg-white/5 hover:border-white/10 transition-all text-left group"
>
<div className="w-10 h-10 rounded-xl bg-slate-800 flex items-center justify-center group-hover:scale-110 transition-transform">
{item.gear_type === "weapon" ? <Sword className="w-5 h-5 text-orange-400" /> :
item.gear_type === "armor" || item.gear_type === "shield" ? <Shield className="w-5 h-5 text-indigo-400" /> :
<Box className="w-5 h-5 text-emerald-400" />}
</div>
<div>
<div className="font-medium text-white truncate max-w-[120px]">{item.name}</div>
<div className="text-xs text-slate-500 capitalize">{item.gear_type || "Item"}</div>
<div className="mt-1"><Ms ms={item._ms} /></div>
</div>
</button>
))}
</div>
</div>
</div>
)}
</motion.div>
);
}
function EnemyCard({ enemy, onItemClick, allItems, allRegions }: { enemy: Enemy, onItemClick: (item: Item) => void, allItems?: Item[], allRegions?: Region[] }) {
const [expanded, setExpanded] = useState(false);
// Find region name
const regionName = allRegions?.find(r => r.id === enemy.region_id)?.name;
return (
<div className="bg-slate-900 border border-white/5 rounded-2xl overflow-hidden transition-all duration-300">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between p-5 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-rose-500/10 flex items-center justify-center border border-rose-500/20 shrink-0">
<Sword className="w-6 h-6 text-rose-400" />
</div>
<div className="text-left flex-1">
<div className="font-bold text-white text-lg">{enemy.name}</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs font-medium px-2 py-0.5 rounded bg-slate-800 text-slate-300">Lvl {enemy.level}</span>
<Ms ms={enemy._ms} />
{regionName && (
<span className="text-xs font-medium text-slate-400 flex items-center gap-1 truncate max-w-[150px]">
<MapIcon className="w-3 h-3 text-purple-400" />
{regionName}
</span>
)}
</div>
</div>
</div>
<motion.div animate={{ rotate: expanded ? 90 : 0 }}>
<ChevronRight className="w-5 h-5 text-slate-500" />
</motion.div>
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-white/5 bg-black/20"
>
<div className="p-5 grid grid-cols-2 gap-4">
<div className="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div className="text-xs text-slate-500 mb-1">Health</div>
<div className="font-bold text-white flex items-center gap-2">
<Heart className="w-4 h-4 text-rose-500" />
{enemy.health}
</div>
</div>
<div className="bg-slate-900/50 p-4 rounded-xl border border-white/5">
<div className="text-xs text-slate-500 mb-1">Damage Output</div>
<div className="font-bold text-white flex items-center gap-2">
<Activity className="w-4 h-4 text-orange-500" />
{enemy.damage}
</div>
</div>
<div className="col-span-2 mt-2">
<div className="text-xs text-slate-500 mb-2 uppercase tracking-wider font-semibold">Possible Drops</div>
<div className="flex flex-wrap gap-2">
{enemy.itemsIds && enemy.itemsIds.length > 0 ? (
enemy.itemsIds.map(itemId => {
const found = allItems?.find(i => i.id === itemId);
const itemName = found ? found.name : `Drop #${itemId}`;
return (
<button
key={itemId}
onClick={(e) => { e.stopPropagation(); onItemClick({ id: itemId, name: itemName }); }}
className="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 border border-white/10 rounded-lg text-sm text-slate-300 transition-colors flex items-center gap-2"
>
<Droplets className="w-3 h-3 text-emerald-400" />
{itemName}
</button>
)})
) : (
<div className="text-sm text-slate-500 italic">No specific drops recorded.</div>
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function SearchView({ universe, timings, dbLabel, onItemClick, onRegionClick }: { universe: any, timings: Timings | null, dbLabel: string, onItemClick: (item: Item) => void, onRegionClick: (r: Region) => void }) {
const [query, setQuery] = useState("");
if (!universe) return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
</div>
);
const q = query.toLowerCase();
const regionsMatch = universe.regions.filter((r: Region) => r.name.toLowerCase().includes(q));
const enemiesMatch = universe.enemies.filter((e: Enemy) => e.name.toLowerCase().includes(q));
const itemsMatch = universe.items.filter((i: Item) => i.name.toLowerCase().includes(q));
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="max-w-5xl mx-auto space-y-8 h-full flex flex-col"
>
<TimingPanel title={`${dbLabel} · Universe geladen`} timings={timings} />
<div className="relative">
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search for regions, items, enemies..."
className="w-full bg-slate-900 border border-white/10 rounded-2xl py-4 pl-12 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-all shadow-xl"
/>
</div>
<div className="flex-1 overflow-y-auto space-y-8 pb-12">
{regionsMatch.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-slate-300 flex items-center gap-2">
<MapIcon className="w-5 h-5" /> Regions
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{regionsMatch.map((r: Region) => (
<button
key={r.id}
onClick={() => onRegionClick(r)}
className="bg-slate-900 p-4 rounded-xl border border-white/5 hover:bg-white/10 hover:border-purple-500/50 transition-all flex items-center justify-between text-left group"
>
<span className="font-medium text-white group-hover:text-purple-200 transition-colors">{r.name}</span>
<ChevronRight className="w-4 h-4 text-slate-500 group-hover:text-purple-400 transition-colors" />
</button>
))}
</div>
</div>
)}
{enemiesMatch.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-slate-300 flex items-center gap-2">
<Crosshair className="w-5 h-5" /> Enemies
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{enemiesMatch.map((e: Enemy) => (
<EnemyCard key={e.id} enemy={e} onItemClick={onItemClick} allItems={universe.items} allRegions={universe.regions} />
))}
</div>
</div>
)}
{itemsMatch.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-slate-300 flex items-center gap-2">
<Box className="w-5 h-5" /> Items
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{itemsMatch.map((i: Item) => (
<button
key={i.id}
onClick={() => onItemClick(i)}
className="bg-slate-900 p-4 rounded-xl border border-white/5 hover:bg-white/5 transition-colors text-left group"
>
<div className="font-medium text-white mb-1 group-hover:text-indigo-300 transition-colors">{i.name}</div>
<div className="text-xs text-slate-500 capitalize">{i.gear_type || "Item"}</div>
</button>
))}
</div>
</div>
)}
{query && regionsMatch.length === 0 && enemiesMatch.length === 0 && itemsMatch.length === 0 && (
<div className="text-center text-slate-500 py-12">
No results found for "{query}".
</div>
)}
</div>
</motion.div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 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";
function start() {
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
+11
View File
@@ -0,0 +1,11 @@
@import "tailwindcss";
@layer base {
:root {
@apply font-sans;
}
body {
@apply m-0 p-0 overflow-hidden bg-slate-950 text-slate-200;
}
}
+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>Bun + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
+244
View File
@@ -0,0 +1,244 @@
import { serve, RedisClient } from "bun";
import { Client } from "memjs";
import { Database } from "arangojs";
import index from "./index.html";
const redis = new RedisClient("redis://localhost:40379");
const memcached = Client.create("localhost:41211");
const arango = new Database({ url: "http://localhost:48529" });
const arangoKv = arango.collection("kv");
type Timing = { op: string; key: string; ms: number };
type Adapter = {
timings: Timing[];
getObject: (key: string) => Promise<any>;
getField: (key: string, field: string) => Promise<any>;
getIds: (key: string) => Promise<string[]>;
};
function stringifyObj(o: any) {
if (!o) return null;
const out: any = {};
for (const k in o) out[k] = o[k] == null ? o[k] : String(o[k]);
return out;
}
function redisAdapter(): Adapter {
const timings: Timing[] = [];
const track = async <T>(op: string, key: string, fn: () => Promise<T>): Promise<T> => {
const start = performance.now();
const res = await fn();
timings.push({ op, key, ms: performance.now() - start });
return res;
};
return {
timings,
getObject: (key) =>
track("hgetall", key, async () => {
const o = await redis.hgetall(key);
return o && Object.keys(o).length ? o : null;
}),
getField: (key, field) => track("hget", key, () => redis.hget(key, field)),
getIds: (key) => track("smembers", key, async () => (await redis.smembers(key)) || []),
};
}
function memcachedAdapter(): Adapter {
const timings: Timing[] = [];
const getJson = async (key: string) => {
const { value } = await memcached.get(key);
return value ? JSON.parse(value.toString()) : null;
};
const track = async <T>(op: string, key: string, fn: () => Promise<T>): Promise<T> => {
const start = performance.now();
const res = await fn();
timings.push({ op, key, ms: performance.now() - start });
return res;
};
return {
timings,
getObject: (key) => track("get", key, async () => stringifyObj(await getJson(key))),
getField: (key, field) => track("get", key, async () => (await getJson(key))?.[field] ?? null),
getIds: (key) => track("get", key, async () => ((await getJson(key)) || []).map(String)),
};
}
function arangoAdapter(): Adapter {
const timings: Timing[] = [];
const getVal = async (key: string) => {
const doc = await arangoKv.document(key, { graceful: true });
return doc ? doc.value : null;
};
const track = async <T>(op: string, key: string, fn: () => Promise<T>): Promise<T> => {
const start = performance.now();
const res = await fn();
timings.push({ op, key, ms: performance.now() - start });
return res;
};
return {
timings,
getObject: (key) => track("document", key, async () => stringifyObj(await getVal(key))),
getField: (key, field) => track("document", key, async () => (await getVal(key))?.[field] ?? null),
getIds: (key) => track("document", key, async () => ((await getVal(key)) || []).map(String)),
};
}
function getAdapter(db: string): Adapter {
if (db === "memcached") return memcachedAdapter();
if (db === "arangodb") return arangoAdapter();
return redisAdapter();
}
function timingSummary(timings: Timing[]) {
const total = timings.reduce((s, t) => s + t.ms, 0);
return { count: timings.length, total, ops: timings };
}
const sumMs = (timings: Timing[]) => timings.reduce((s, t) => s + t.ms, 0);
const regionCache: Record<string, { id: string; region: any }> = {};
async function handlePlayer(adapter: Adapter, db: string) {
const player = await adapter.getObject("player:1");
if (!player || !player.id) return null;
const currentRegionId = player.region_id;
let region = null;
let regionCached = false;
if (currentRegionId) {
const cached = regionCache[db];
if (cached && cached.id === currentRegionId) {
region = cached.region;
regionCached = true;
} else {
const regionStart = sumMs(adapter.timings);
const regionName = await adapter.getField(`region:${currentRegionId}`, "name");
const enemiesIds = await adapter.getIds(`region:${currentRegionId}:enemies`);
const itemsIds = await adapter.getIds(`region:${currentRegionId}:items`);
const regionMs = sumMs(adapter.timings) - regionStart;
const enemies = [];
for (const id of enemiesIds) {
const s = sumMs(adapter.timings);
const enemy = await adapter.getObject(`enemy:${id}`);
if (enemy && enemy.id) {
enemy._ms = sumMs(adapter.timings) - s;
enemies.push(enemy);
}
}
const items = [];
for (const id of itemsIds) {
const s = sumMs(adapter.timings);
const item = await adapter.getObject(`item:${id}`);
if (item && item.id) {
item._ms = sumMs(adapter.timings) - s;
items.push(item);
}
}
region = { id: currentRegionId, name: regionName, enemies, items, _ms: regionMs };
regionCache[db] = { id: currentRegionId, region };
}
}
return { player, region, regionCached };
}
async function handleUniverse(adapter: Adapter) {
const regions = [];
for (let i = 1; i <= 16; i++) {
const s = sumMs(adapter.timings);
const name = await adapter.getField(`region:${i}`, "name");
if (name) {
const enemiesIds = await adapter.getIds(`region:${i}:enemies`);
const itemsIds = await adapter.getIds(`region:${i}:items`);
regions.push({ id: i.toString(), name, enemiesIds, itemsIds, _ms: sumMs(adapter.timings) - s });
}
}
const enemies = [];
for (let i = 1; i <= 30; i++) {
const s = sumMs(adapter.timings);
const enemy = await adapter.getObject(`enemy:${i}`);
if (enemy && enemy.id) {
const itemsIds = await adapter.getIds(`enemy:${i}:items`);
enemy.itemsIds = itemsIds || [];
enemy._ms = sumMs(adapter.timings) - s;
enemies.push(enemy);
}
}
const items = [];
for (let i = 1; i <= 50; i++) {
const s = sumMs(adapter.timings);
const item = await adapter.getObject(`item:${i}`);
if (item && item.id) {
if (item.gear_type && item.gear_id) {
const gearStats = await adapter.getObject(`${item.gear_type}:${item.gear_id}`);
if (gearStats) {
if (gearStats.item_type_id) {
const typeInfo = await adapter.getObject(`item_type:${gearStats.item_type_id}`);
if (typeInfo && typeInfo.name) gearStats.Item_Type = typeInfo.name;
delete gearStats.item_type_id;
}
if (gearStats.item_effect_id) {
const effectInfo = await adapter.getObject(`item_effect:${gearStats.item_effect_id}`);
if (effectInfo && effectInfo.name) {
gearStats.Item_Effect = `${effectInfo.name} ${effectInfo.value ? `(+${effectInfo.value})` : ""}`;
}
delete gearStats.item_effect_id;
}
delete gearStats.id;
item.stats = gearStats;
}
}
item._ms = sumMs(adapter.timings) - s;
items.push(item);
}
}
return { regions, enemies, items };
}
const server = serve({
routes: {
"/*": index,
"/api/player": async (req) => {
const db = new URL(req.url).searchParams.get("db") || "redis";
try {
const adapter = getAdapter(db);
const result = await handlePlayer(adapter, db);
if (!result) {
return Response.json({ error: "Player not found", db }, { status: 404 });
}
return Response.json({ ...result, db, _timings: timingSummary(adapter.timings) });
} catch (e: any) {
return Response.json({ error: e.message, db }, { status: 500 });
}
},
"/api/universe": async (req) => {
const db = new URL(req.url).searchParams.get("db") || "redis";
try {
const adapter = getAdapter(db);
const result = await handleUniverse(adapter);
return Response.json({ ...result, db, _timings: timingSummary(adapter.timings) });
} catch (e: any) {
return Response.json({ error: e.message, db }, { status: 500 });
}
},
},
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

+36
View File
@@ -0,0 +1,36 @@
{
"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,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}