init
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
vite-dev*.log
|
||||||
@@ -1 +1,70 @@
|
|||||||
# tssbot.web
|
# tssbot.web
|
||||||
|
|
||||||
|
React + Vite + Tailwind v4 web shell for Toothless' TSS Bot.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
- `/` landing page
|
||||||
|
- `/teams` TSS team leaderboard
|
||||||
|
- `/teams/:teamname` generated team profile with roster, summary, rating history, and battle results
|
||||||
|
- `/battle-logs` Battle Logs
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The development server runs on <http://localhost:3001>.
|
||||||
|
|
||||||
|
By default, `/api/*` and `/health` requests are proxied to `http://localhost:6000`. Override
|
||||||
|
that with `VITE_API_TARGET`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VITE_API_TARGET=http://localhost:8080 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production with PM2
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The production server runs on <http://localhost:3001> and proxies `/api/*` plus
|
||||||
|
`/health` to `API_UPSTREAM`, which defaults to `http://127.0.0.1:6000`.
|
||||||
|
|
||||||
|
Override the API target before starting PM2 if needed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
API_UPSTREAM=http://127.0.0.1:8080 pm2 start ecosystem.config.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub webhook
|
||||||
|
|
||||||
|
The webhook process listens on port `3011` at `/github`. Configure GitHub to send
|
||||||
|
push events there.
|
||||||
|
|
||||||
|
Set a webhook secret before starting PM2 if you want signature validation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GITHUB_WEBHOOK_SECRET=your-secret pm2 start ecosystem.config.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
On PowerShell, set `$env:GITHUB_WEBHOOK_SECRET = "your-secret"` before starting
|
||||||
|
PM2, or put the value directly in `ecosystem.config.cjs`.
|
||||||
|
|
||||||
|
The default deploy flow is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git pull --ff-only
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 reload tssbot-web --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
Only processes listed in `PM2_RESTART_TARGETS` are reloaded. The default is
|
||||||
|
`tssbot-web`, so unrelated PM2 processes are left alone. The webhook exits after
|
||||||
|
24 hours so PM2 restarts it cleanly.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { TRUNK_TOP_CSS } from "./Tree";
|
||||||
|
|
||||||
|
interface Leaf {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
speed: number;
|
||||||
|
drift: number;
|
||||||
|
rotation: number;
|
||||||
|
rotationSpeed: number;
|
||||||
|
opacity: number;
|
||||||
|
sway: number;
|
||||||
|
swaySpeed: number;
|
||||||
|
phase: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FallingLeaves({ treeRef }: { treeRef: React.RefObject<HTMLCanvasElement | null> }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current!;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
let animId: number;
|
||||||
|
const leaves: Leaf[] = [];
|
||||||
|
const MAX_LEAVES = 50;
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
const rect = parent?.getBoundingClientRect();
|
||||||
|
canvas.width = Math.max(1, Math.round(rect?.width || window.innerWidth));
|
||||||
|
canvas.height = Math.max(1, Math.round(rect?.height || window.innerHeight));
|
||||||
|
}
|
||||||
|
resize();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
|
function spawnLeaf(): Leaf {
|
||||||
|
const treeRect = treeRef.current?.getBoundingClientRect();
|
||||||
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
const spawnY = treeRect ? treeRect.top - canvasRect.top + TRUNK_TOP_CSS : canvas.height * 0.4;
|
||||||
|
const spawnX = treeRect
|
||||||
|
? treeRect.left - canvasRect.left + Math.random() * treeRect.width * 0.8 + treeRect.width * 0.1
|
||||||
|
: Math.random() * canvas.width * 0.6 + canvas.width * 0.2;
|
||||||
|
return {
|
||||||
|
x: spawnX,
|
||||||
|
y: spawnY,
|
||||||
|
size: Math.random() * 4 + 2,
|
||||||
|
speed: Math.random() * 0.3 + 0.15,
|
||||||
|
drift: Math.random() * 0.5 - 0.25,
|
||||||
|
rotation: Math.random() * Math.PI * 2,
|
||||||
|
rotationSpeed: (Math.random() - 0.5) * 0.02,
|
||||||
|
opacity: Math.random() * 0.25 + 0.08,
|
||||||
|
sway: Math.random() * 30 + 15,
|
||||||
|
swaySpeed: Math.random() * 0.008 + 0.003,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let spawnTimer = 0;
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
spawnTimer++;
|
||||||
|
if (spawnTimer > 30 && leaves.length < MAX_LEAVES) {
|
||||||
|
leaves.push(spawnLeaf());
|
||||||
|
spawnTimer = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = leaves.length - 1; i >= 0; i--) {
|
||||||
|
const l = leaves[i];
|
||||||
|
l.y += l.speed;
|
||||||
|
l.x += l.drift + Math.sin(l.phase) * l.swaySpeed * l.sway * 0.05;
|
||||||
|
l.phase += l.swaySpeed;
|
||||||
|
l.rotation += l.rotationSpeed;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(l.x, l.y);
|
||||||
|
ctx.rotate(l.rotation);
|
||||||
|
ctx.globalAlpha = l.opacity;
|
||||||
|
ctx.fillStyle = "#fdb068";
|
||||||
|
|
||||||
|
const s = Math.round(l.size);
|
||||||
|
ctx.fillRect(-s / 2, -s / 2, s, s);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
if (l.y > canvas.height + 10) {
|
||||||
|
leaves.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animId = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="falling-leaves" />;
|
||||||
|
}
|
||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
import React, { forwardRef, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const PX = 2;
|
||||||
|
const W = 500;
|
||||||
|
const H = 420;
|
||||||
|
const LEAF_FLOOR = H - 60;
|
||||||
|
|
||||||
|
|
||||||
|
function buildTree(seed: number) {
|
||||||
|
const woodGrid = new Uint8Array(W * H);
|
||||||
|
const leafGrid = new Uint8Array(W * H);
|
||||||
|
|
||||||
|
let s = seed;
|
||||||
|
function rand() {
|
||||||
|
s = (s * 16807 + 0) % 2147483647;
|
||||||
|
return (s & 0x7fffffff) / 0x7fffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWood(x: number, y: number, shade: number) {
|
||||||
|
const px = Math.round(x), py = Math.round(y);
|
||||||
|
if (px >= 0 && px < W && py >= 0 && py < H) {
|
||||||
|
const idx = py * W + px;
|
||||||
|
if (woodGrid[idx] === 0) woodGrid[idx] = shade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLeaf(x: number, y: number, shade: number) {
|
||||||
|
const px = Math.round(x), py = Math.round(y);
|
||||||
|
if (px >= 0 && px < W && py >= 0 && py < LEAF_FLOOR) {
|
||||||
|
const idx = py * W + px;
|
||||||
|
if (shade > leafGrid[idx]) leafGrid[idx] = shade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLeaf(x: number, y: number, shade: number) {
|
||||||
|
const px = Math.round(x), py = Math.round(y);
|
||||||
|
setLeaf(px, py, shade);
|
||||||
|
const r = rand();
|
||||||
|
if (r < 0.60) setLeaf(px + (rand() > 0.5 ? 1 : -1), py, shade);
|
||||||
|
if (r < 0.32) setLeaf(px, py + (rand() > 0.45 ? -1 : 1), shade);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leafScatter(cx: number, cy: number, radius: number, count: number) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const a = rand() * Math.PI * 2;
|
||||||
|
const r = Math.sqrt(rand()) * radius;
|
||||||
|
const lx = cx + Math.cos(a) * r * 0.5;
|
||||||
|
const vertStretch = 1.0 + (cy / H) * 3.0;
|
||||||
|
const ly = cy + Math.abs(Math.sin(a)) * r * vertStretch;
|
||||||
|
const yFr = (ly - cy) / radius;
|
||||||
|
const shade = yFr < 0.5 ? 3 : yFr < 1.2 ? 2 : 1;
|
||||||
|
drawLeaf(lx, ly, shade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLine(
|
||||||
|
x0: number, y0: number, x1: number, y1: number,
|
||||||
|
thickness: number, shade: number,
|
||||||
|
) {
|
||||||
|
const dx = x1 - x0, dy = y1 - y0;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const steps = Math.max(Math.ceil(dist * 2), 1);
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
const bx = x0 + dx * t, by = y0 + dy * t;
|
||||||
|
const r = (thickness / 2) * (1 - t * 0.4);
|
||||||
|
for (let oy = -Math.ceil(r); oy <= Math.ceil(r); oy++)
|
||||||
|
for (let ox = -Math.ceil(r); ox <= Math.ceil(r); ox++)
|
||||||
|
if (ox * ox + oy * oy <= r * r) setWood(bx + ox, by + oy, shade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function branch(
|
||||||
|
x: number, y: number,
|
||||||
|
angle: number, length: number,
|
||||||
|
thickness: number, depth: number,
|
||||||
|
) {
|
||||||
|
const endX = x + Math.cos(angle) * length;
|
||||||
|
const endY = y + Math.sin(angle) * length;
|
||||||
|
|
||||||
|
const woodShade = thickness > 5 ? 1 : thickness > 2.5 ? 2 : 3;
|
||||||
|
drawLine(x, y, endX, endY, thickness, woodShade);
|
||||||
|
|
||||||
|
if (depth <= 0 || length < 1.5) {
|
||||||
|
const r = 4 + rand() * 6;
|
||||||
|
|
||||||
|
const normalised = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
||||||
|
const deg = normalised * (180 / Math.PI);
|
||||||
|
const isNearHorizontal = deg < 30 || deg > 150;
|
||||||
|
|
||||||
|
if (isNearHorizontal) {
|
||||||
|
for (let i = 0; i < Math.round(r * r * 0.8); i++) {
|
||||||
|
const ox = (rand() - 0.5) * r * 2;
|
||||||
|
const oy = -rand() * r * 2.5;
|
||||||
|
const shade = oy < -r ? 3 : 2;
|
||||||
|
drawLeaf(endX + ox, endY + oy, shade);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
leafScatter(endX, endY, r, Math.round(r * r * 0.6));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth === 1) {
|
||||||
|
const steps = Math.ceil(length / 5);
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
const mx = x + Math.cos(angle) * length * t;
|
||||||
|
const my = y + Math.sin(angle) * length * t;
|
||||||
|
leafScatter(mx, my, 3 + rand() * 2, 8 + Math.round(rand() * 6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numChildren = depth > 4 ? 2 : 3 + (rand() > 0.6 ? 1 : 0);
|
||||||
|
for (let i = 0; i < numChildren; i++) {
|
||||||
|
const spread = 0.30 + rand() * 0.20;
|
||||||
|
const angleOffset = (i - (numChildren - 1) / 2) * spread;
|
||||||
|
const droopBias = 0;
|
||||||
|
const childAngle = angle + angleOffset + (rand() - 0.5) * 0.1 + droopBias;
|
||||||
|
const lenFactor = 0.60 + rand() * 0.12;
|
||||||
|
const thickFactor = 0.50 + rand() * 0.10;
|
||||||
|
branch(endX, endY, childAngle, length * lenFactor,
|
||||||
|
Math.max(1, thickness * thickFactor), depth - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth > 1 && rand() > 0.25) {
|
||||||
|
const t = 0.25 + rand() * 0.35;
|
||||||
|
const sx = x + Math.cos(angle) * length * t;
|
||||||
|
const sy = y + Math.sin(angle) * length * t;
|
||||||
|
const side = rand() > 0.5 ? 1 : -1;
|
||||||
|
branch(sx, sy,
|
||||||
|
angle + side * (0.30 + rand() * 0.35), length * 0.5,
|
||||||
|
Math.max(1, thickness * 0.45), depth - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cx = W / 2;
|
||||||
|
const trunkTop = H - 150;
|
||||||
|
const base = H - 50;
|
||||||
|
|
||||||
|
|
||||||
|
for (let ty = trunkTop; ty <= base; ty++) {
|
||||||
|
const t = (ty - trunkTop) / (base - trunkTop);
|
||||||
|
const width = 10 + t * t * 16;
|
||||||
|
const hw = Math.ceil(width);
|
||||||
|
for (let ox = -hw; ox <= hw; ox++) {
|
||||||
|
const px = Math.round(cx + ox), py = ty;
|
||||||
|
if (px < 0 || px >= W || py < 0 || py >= H) continue;
|
||||||
|
woodGrid[py * W + px] = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
branch(cx - 30, trunkTop + 8, -Math.PI * 0.95, 70, 11, 7);
|
||||||
|
branch(cx - 22, trunkTop + 4, -Math.PI * 0.87, 68, 10, 7);
|
||||||
|
branch(cx - 14, trunkTop, -Math.PI * 0.78, 66, 10, 7);
|
||||||
|
branch(cx - 8, trunkTop - 4, -Math.PI * 0.68, 64, 9, 7);
|
||||||
|
branch(cx - 8, trunkTop + 4, -Math.PI * 0.70, 71, 10, 7);
|
||||||
|
branch(cx - 4, trunkTop - 7, -Math.PI * 0.60, 62, 9, 7);
|
||||||
|
branch(cx, trunkTop - 6, -Math.PI * 0.55, 70, 9, 7);
|
||||||
|
branch(cx, trunkTop - 7, -Math.PI * 0.50, 73, 9, 7);
|
||||||
|
branch(cx, trunkTop - 7, -Math.PI * 0.55, 77, 9, 7);
|
||||||
|
branch(cx, trunkTop - 6, -Math.PI * 0.45, 70, 9, 7);
|
||||||
|
branch(cx, trunkTop - 6, -Math.PI * 0.60, 70, 9, 7);
|
||||||
|
branch(cx, trunkTop - 6, -Math.PI * 0.40, 70, 9, 7);
|
||||||
|
branch(cx + 30, trunkTop + 8, -Math.PI * 0.1, 65, 11, 7);
|
||||||
|
branch(cx + 22, trunkTop + 4, -Math.PI * 0.18, 65, 10, 7);
|
||||||
|
branch(cx + 14, trunkTop, -Math.PI * 0.22, 63, 10, 7);
|
||||||
|
branch(cx + 14, trunkTop, -Math.PI * 0.25, 65, 10, 7);
|
||||||
|
branch(cx + 8, trunkTop - 4, -Math.PI * 0.32, 61, 9, 7);
|
||||||
|
branch(cx + 4, trunkTop - 7, -Math.PI * 0.40, 62, 9, 7);
|
||||||
|
branch(cx + 2, trunkTop + 4, -Math.PI * 0.45, 67, 10, 7);
|
||||||
|
branch(cx + 2, trunkTop + 5, -Math.PI * 0.17, 67, 10, 7);
|
||||||
|
branch(cx - 10, trunkTop + 20, -Math.PI * 0.75, 45, 7, 6);
|
||||||
|
branch(cx - 5, trunkTop + 20, -Math.PI * 0.62, 42, 6, 6);
|
||||||
|
branch(cx, trunkTop + 18, -Math.PI * 0.50, 44, 7, 6);
|
||||||
|
branch(cx + 10, trunkTop + 20, -Math.PI * 0.25, 45, 7, 6);
|
||||||
|
branch(cx + 5, trunkTop + 20, -Math.PI * 0.38, 42, 6, 6);
|
||||||
|
branch(cx + 3, trunkTop - 6, -Math.PI * 0.68, 10, 9, 7);
|
||||||
|
branch(cx - 3, trunkTop - 7, -Math.PI * 0.32, 10, 9, 7);
|
||||||
|
branch(cx + 3, trunkTop - 6, -Math.PI * 0.7, 5, 9, 7);
|
||||||
|
branch(cx - 3, trunkTop - 7, -Math.PI * 0.3, 5, 9, 7);
|
||||||
|
branch(cx + 3, trunkTop - 6, -Math.PI * 0.73, 20, 9, 7);
|
||||||
|
branch(cx, trunkTop - 3, -Math.PI * 0.15, 90, 5, 3);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { woodGrid, leafGrid };
|
||||||
|
}
|
||||||
|
const WOOD_COLORS = ['', '#c96303', '#c96303', '#c96303', '#321901'];
|
||||||
|
const LEAF_COLORS = ['', '#ed5145', '#fdb068', '#fee5cd'];
|
||||||
|
|
||||||
|
export const TRUNK_TOP_CSS = (H - 150) + 10;
|
||||||
|
|
||||||
|
const Tree = forwardRef<HTMLCanvasElement>(function Tree(_, ref) {
|
||||||
|
const internalRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const canvasRef = (ref as React.RefObject<HTMLCanvasElement>) ?? internalRef;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current!;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
canvas.width = W * PX;
|
||||||
|
canvas.height = H * PX;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const { woodGrid, leafGrid } = buildTree(55);
|
||||||
|
|
||||||
|
for (let y = 0; y < H; y++)
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const v = woodGrid[y * W + x];
|
||||||
|
if (v) {
|
||||||
|
ctx.fillStyle = WOOD_COLORS[v] ?? '#321901';
|
||||||
|
ctx.fillRect(x * PX, y * PX, PX, PX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < H; y++)
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const v = leafGrid[y * W + x];
|
||||||
|
if (v) {
|
||||||
|
ctx.fillStyle = LEAF_COLORS[v];
|
||||||
|
ctx.fillRect(x * PX, y * PX, PX, PX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUND_Y = H - 50;
|
||||||
|
const groundColors = ['#fee5cd', '#fdca9b', '#fdb068'];
|
||||||
|
const grassColors = ['#ed5145', '#fb7b04', '#c96303', '#f17c74'];
|
||||||
|
|
||||||
|
let gs = 12345;
|
||||||
|
function grassRand() {
|
||||||
|
gs = (gs * 16807 + 0) % 2147483647;
|
||||||
|
return (gs & 0x7fffffff) / 0x7fffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const wave = Math.sin(x * 0.05) * 3 + Math.sin(x * 0.12) * 2;
|
||||||
|
const groundTop = Math.round(GROUND_Y + wave);
|
||||||
|
for (let y = groundTop; y < H; y++) {
|
||||||
|
const depth = (y - groundTop) / (H - groundTop);
|
||||||
|
const ci = depth < 0.3 ? 0 : depth < 0.7 ? 1 : 2;
|
||||||
|
ctx.fillStyle = groundColors[ci];
|
||||||
|
ctx.fillRect(x * PX, y * PX, PX, PX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (grassRand() > 0.35) continue;
|
||||||
|
const wave = Math.sin(x * 0.05) * 3 + Math.sin(x * 0.12) * 2;
|
||||||
|
const surfaceY = Math.round(GROUND_Y + wave);
|
||||||
|
const bladeH = Math.floor(grassRand() * 4) + 2;
|
||||||
|
const color = grassColors[Math.floor(grassRand() * grassColors.length)];
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
for (let b = 0; b < bladeH; b++) {
|
||||||
|
ctx.fillRect(x * PX, (surfaceY - b - 1) * PX, PX, PX);
|
||||||
|
}
|
||||||
|
if (grassRand() > 0.6 && x + 1 < W) {
|
||||||
|
ctx.fillRect((x + 1) * PX, (surfaceY - 1) * PX, PX, PX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="tree"
|
||||||
|
style={{ imageRendering: "pixelated" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Tree;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'tssbot-web',
|
||||||
|
script: 'server.cjs',
|
||||||
|
cwd: __dirname,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: process.env.PORT || 3001,
|
||||||
|
API_UPSTREAM: process.env.API_UPSTREAM || 'http://127.0.0.1:6000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tssbot-webhook',
|
||||||
|
script: 'webhook.cjs',
|
||||||
|
cwd: __dirname,
|
||||||
|
autorestart: true,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
WEBHOOK_PORT: process.env.WEBHOOK_PORT || 3011,
|
||||||
|
GITHUB_WEBHOOK_SECRET: process.env.GITHUB_WEBHOOK_SECRET || '',
|
||||||
|
PM2_RESTART_TARGETS: process.env.PM2_RESTART_TARGETS || 'tssbot-web',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['vite.config.js'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
PORT=3001
|
||||||
|
API_UPSTREAM=http://127.0.0.1:6000
|
||||||
|
|
||||||
|
WEBHOOK_PORT=3011
|
||||||
|
GITHUB_WEBHOOK_SECRET=change-me
|
||||||
|
PM2_RESTART_TARGETS=tssbot-web
|
||||||
+13
@@ -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" />
|
||||||
|
<meta name="theme-color" content="#ff9d00" />
|
||||||
|
<title>TSS Bot</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3398
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "tssbot-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 3001",
|
||||||
|
"build": "vite build",
|
||||||
|
"serve": "node server.cjs",
|
||||||
|
"webhook": "node webhook.cjs",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 3001",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.27.0",
|
||||||
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
|
"@types/react": "^19.1.5",
|
||||||
|
"@types/react-dom": "^19.1.5",
|
||||||
|
"eslint": "^9.27.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^16.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
const fs = require('node:fs')
|
||||||
|
const http = require('node:http')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 3001)
|
||||||
|
const API_UPSTREAM = process.env.API_UPSTREAM || 'http://127.0.0.1:6000'
|
||||||
|
const DIST_DIR = path.join(__dirname, 'dist')
|
||||||
|
|
||||||
|
const mimeTypes = {
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.map': 'application/json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, status, body, headers = {}) {
|
||||||
|
res.writeHead(status, headers)
|
||||||
|
res.end(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyRequest(req, res) {
|
||||||
|
const target = new URL(req.url, API_UPSTREAM)
|
||||||
|
const proxy = http.request(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
method: req.method,
|
||||||
|
headers: {
|
||||||
|
...req.headers,
|
||||||
|
host: target.host,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(proxyRes) => {
|
||||||
|
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers)
|
||||||
|
proxyRes.pipe(res)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
proxy.on('error', (error) => {
|
||||||
|
send(
|
||||||
|
res,
|
||||||
|
502,
|
||||||
|
JSON.stringify({ error: 'API proxy failed', detail: error.message }),
|
||||||
|
{ 'content-type': 'application/json; charset=utf-8' },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
req.pipe(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(req, res) {
|
||||||
|
const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
|
||||||
|
const relativePath = requestPath === '/' ? '/index.html' : requestPath
|
||||||
|
const filePath = path.normalize(path.join(DIST_DIR, relativePath))
|
||||||
|
|
||||||
|
if (!filePath.startsWith(DIST_DIR)) {
|
||||||
|
return send(res, 403, 'Forbidden', { 'content-type': 'text/plain; charset=utf-8' })
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.readFile(filePath, (error, data) => {
|
||||||
|
if (error) {
|
||||||
|
fs.readFile(path.join(DIST_DIR, 'index.html'), (indexError, indexData) => {
|
||||||
|
if (indexError) {
|
||||||
|
return send(res, 404, 'Build not found. Run npm run build first.', {
|
||||||
|
'content-type': 'text/plain; charset=utf-8',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
send(res, 200, indexData, { 'content-type': mimeTypes['.html'] })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath)
|
||||||
|
send(res, 200, data, {
|
||||||
|
'content-type': mimeTypes[ext] || 'application/octet-stream',
|
||||||
|
'cache-control': ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
http
|
||||||
|
.createServer((req, res) => {
|
||||||
|
if (req.url === '/health' || req.url.startsWith('/api/')) {
|
||||||
|
proxyRequest(req, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serveStatic(req, res)
|
||||||
|
})
|
||||||
|
.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`tssbot-web serving http://localhost:${PORT}`)
|
||||||
|
console.log(`proxying API requests to ${API_UPSTREAM}`)
|
||||||
|
})
|
||||||
+957
@@ -0,0 +1,957 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import Tree from '../Tree/Tree'
|
||||||
|
import FallingLeaves from '../Tree/FallingLeaves'
|
||||||
|
|
||||||
|
const numberFormat = new Intl.NumberFormat('en-GB')
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiEndpoints = {
|
||||||
|
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||||
|
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
||||||
|
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
||||||
|
history: (name) => `/api/tss/teams/${encodeURIComponent(name)}/history`,
|
||||||
|
games: (name) => `/api/tss/teams/${encodeURIComponent(name)}/games`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: 'Home' },
|
||||||
|
{ path: '/teams', label: 'Team leaderboard' },
|
||||||
|
{ path: '/battle-logs', label: 'Battle Logs' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function fetchJson(path, signal) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
const body = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(body?.error || `Request failed with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRoute(pathname = window.location.pathname) {
|
||||||
|
if (pathname === '/') return { page: 'home', teamName: '' }
|
||||||
|
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
||||||
|
if (pathname.startsWith('/teams/')) {
|
||||||
|
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
||||||
|
return { page: 'team', teamName }
|
||||||
|
}
|
||||||
|
if (pathname === '/battle-logs' || pathname === '/live') return { page: 'battle-logs', teamName: '' }
|
||||||
|
return { page: 'home', teamName: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamPath(name) {
|
||||||
|
return `/teams/${encodeURIComponent(name)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return numberFormat.format(Number(value || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp) {
|
||||||
|
if (!timestamp) return 'Unknown time'
|
||||||
|
return dateFormat.format(new Date(Number(timestamp) * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestTeamName(team) {
|
||||||
|
return team?.tag_name || team?.short_name || team?.long_name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecentTssGames(teams, signal) {
|
||||||
|
const teamNames = teams.map(bestTeamName).filter(Boolean).slice(0, 12)
|
||||||
|
|
||||||
|
if (!teamNames.length) {
|
||||||
|
return { matches: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await Promise.allSettled(
|
||||||
|
teamNames.map((name) => fetchJson(apiEndpoints.games(name), signal).then((data) => ({ name, data }))),
|
||||||
|
)
|
||||||
|
const bySession = new Map()
|
||||||
|
|
||||||
|
responses.forEach((result) => {
|
||||||
|
if (result.status !== 'fulfilled') return
|
||||||
|
|
||||||
|
const { name, data } = result.value
|
||||||
|
;(data.games || []).forEach((game) => {
|
||||||
|
if (!game.session_id) return
|
||||||
|
|
||||||
|
const existing = bySession.get(game.session_id)
|
||||||
|
const currentTimestamp = Number(game.timestamp || 0)
|
||||||
|
if (existing && Number(existing.timestamp || 0) >= currentTimestamp) return
|
||||||
|
|
||||||
|
bySession.set(game.session_id, {
|
||||||
|
...game,
|
||||||
|
team_name: data.tag_name || name,
|
||||||
|
long_name: data.long_name || '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
matches: Array.from(bySession.values())
|
||||||
|
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
|
||||||
|
.slice(0, 50),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="border-l border-border pl-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-text-soft">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-text">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [route, setRoute] = useState(() => parseRoute())
|
||||||
|
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
||||||
|
const [live, setLive] = useState({ status: 'idle', data: null, error: null })
|
||||||
|
const [teamQuery, setTeamQuery] = useState('')
|
||||||
|
const [searchHint, setSearchHint] = useState({ status: 'idle', name: '' })
|
||||||
|
const [profile, setProfile] = useState({
|
||||||
|
teamName: '',
|
||||||
|
detail: { status: 'idle', data: null, error: null },
|
||||||
|
history: { status: 'idle', data: null, error: null },
|
||||||
|
games: { status: 'idle', data: null, error: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
function navigate(path) {
|
||||||
|
window.history.pushState({}, '', path)
|
||||||
|
setRoute(parseRoute(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPopState = () => setRoute(parseRoute())
|
||||||
|
window.addEventListener('popstate', onPopState)
|
||||||
|
return () => window.removeEventListener('popstate', onPopState)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const title =
|
||||||
|
route.page === 'team' && route.teamName
|
||||||
|
? `${route.teamName} | Toothless' TSS Bot`
|
||||||
|
: route.page === 'teams'
|
||||||
|
? "Team leaderboard | Toothless' TSS Bot"
|
||||||
|
: route.page === 'battle-logs'
|
||||||
|
? "Battle Logs | Toothless' TSS Bot"
|
||||||
|
: "Toothless' TSS Bot"
|
||||||
|
|
||||||
|
document.title = title
|
||||||
|
}, [route.page, route.teamName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = teamQuery.trim()
|
||||||
|
if (query.length < 2) {
|
||||||
|
setSearchHint({ status: 'idle', name: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setSearchHint({ status: 'loading', name: '' })
|
||||||
|
fetchJson(apiEndpoints.resolve(query), controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
const name = data.tag_name || data.short_name || data.long_name || query
|
||||||
|
setSearchHint({ status: 'ready', name })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setSearchHint({ status: 'error', name: '' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 350)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [teamQuery])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!['home', 'teams', 'team', 'battle-logs'].includes(route.page)) return
|
||||||
|
if (leaderboard.status === 'ready' || leaderboard.status === 'loading') return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setLeaderboard({ status: 'loading', data: null, error: null })
|
||||||
|
|
||||||
|
fetchJson(apiEndpoints.teams, controller.signal)
|
||||||
|
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLeaderboard({ status: 'error', data: null, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [leaderboard.status, route.page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!['home', 'teams', 'team', 'battle-logs'].includes(route.page)) return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
fetchJson(apiEndpoints.teams, controller.signal)
|
||||||
|
.then((data) => setLeaderboard({ status: 'ready', data, error: null }))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLeaderboard((current) => ({ ...current, error: error.message }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [route.page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!['home', 'battle-logs'].includes(route.page)) return
|
||||||
|
if (!teams.length) return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setLive((current) =>
|
||||||
|
current.status === 'ready' ? current : { status: 'loading', data: null, error: null },
|
||||||
|
)
|
||||||
|
|
||||||
|
fetchRecentTssGames(teams, controller.signal)
|
||||||
|
.then((data) => setLive({ status: 'ready', data, error: null }))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLive({ status: 'error', data: null, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [route.page, teams])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!['home', 'battle-logs'].includes(route.page)) return
|
||||||
|
if (!teams.length) return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
fetchRecentTssGames(teams, controller.signal)
|
||||||
|
.then((data) => setLive({ status: 'ready', data, error: null }))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLive((current) => ({ ...current, error: error.message }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [route.page, teams])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.page !== 'team' || !route.teamName) return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setProfile({
|
||||||
|
teamName: route.teamName,
|
||||||
|
detail: { status: 'loading', data: null, error: null },
|
||||||
|
history: { status: 'loading', data: null, error: null },
|
||||||
|
games: { status: 'loading', data: null, error: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
Promise.allSettled([
|
||||||
|
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
||||||
|
fetchJson(apiEndpoints.history(route.teamName), controller.signal),
|
||||||
|
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
||||||
|
]).then(([detailResult, historyResult, gamesResult]) => {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
|
||||||
|
setProfile({
|
||||||
|
teamName: route.teamName,
|
||||||
|
detail:
|
||||||
|
detailResult.status === 'fulfilled'
|
||||||
|
? { status: 'ready', data: detailResult.value, error: null }
|
||||||
|
: { status: 'error', data: null, error: detailResult.reason.message },
|
||||||
|
history:
|
||||||
|
historyResult.status === 'fulfilled'
|
||||||
|
? { status: 'ready', data: historyResult.value, error: null }
|
||||||
|
: { status: 'error', data: null, error: historyResult.reason.message },
|
||||||
|
games:
|
||||||
|
gamesResult.status === 'fulfilled'
|
||||||
|
? { status: 'ready', data: gamesResult.value, error: null }
|
||||||
|
: { status: 'error', data: null, error: gamesResult.reason.message },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [route.page, route.teamName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.page !== 'team' || !route.teamName) return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
Promise.allSettled([
|
||||||
|
fetchJson(apiEndpoints.detail(route.teamName), controller.signal),
|
||||||
|
fetchJson(apiEndpoints.history(route.teamName), controller.signal),
|
||||||
|
fetchJson(apiEndpoints.games(route.teamName), controller.signal),
|
||||||
|
]).then(([detailResult, historyResult, gamesResult]) => {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
|
||||||
|
setProfile((current) => ({
|
||||||
|
teamName: route.teamName,
|
||||||
|
detail:
|
||||||
|
detailResult.status === 'fulfilled'
|
||||||
|
? { status: 'ready', data: detailResult.value, error: null }
|
||||||
|
: current.detail,
|
||||||
|
history:
|
||||||
|
historyResult.status === 'fulfilled'
|
||||||
|
? { status: 'ready', data: historyResult.value, error: null }
|
||||||
|
: current.history,
|
||||||
|
games:
|
||||||
|
gamesResult.status === 'fulfilled'
|
||||||
|
? { status: 'ready', data: gamesResult.value, error: null }
|
||||||
|
: current.games,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [route.page, route.teamName])
|
||||||
|
|
||||||
|
const teams = useMemo(
|
||||||
|
() => leaderboard.data?.teams || leaderboard.data?.squadrons || [],
|
||||||
|
[leaderboard.data],
|
||||||
|
)
|
||||||
|
const matches = live.data?.matches || []
|
||||||
|
const topTeamName = bestTeamName(teams[0])
|
||||||
|
const searchPlaceholder =
|
||||||
|
searchHint.status === 'ready' ? `Found ${searchHint.name}` : topTeamName || 'Search teams'
|
||||||
|
|
||||||
|
async function handleTeamSearch(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const name = teamQuery.trim()
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await fetchJson(apiEndpoints.resolve(name))
|
||||||
|
navigate(teamPath(resolved.tag_name || resolved.short_name || resolved.long_name || name))
|
||||||
|
} catch {
|
||||||
|
navigate(teamPath(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeNavPath =
|
||||||
|
route.page === 'team'
|
||||||
|
? '/teams'
|
||||||
|
: route.page === 'battle-logs'
|
||||||
|
? '/battle-logs'
|
||||||
|
: window.location.pathname
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-bg text-text">
|
||||||
|
<header className="sticky top-0 z-50 border-b border-border bg-bg/90 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 px-5 py-3 sm:px-8 xl:flex-row xl:items-center">
|
||||||
|
<button className="shrink-0 text-left" onClick={() => navigate('/')} type="button">
|
||||||
|
<span className="text-lg font-bold tracking-tight">Toothless' TSS Bot</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav className="flex flex-wrap items-center gap-1 rounded-lg bg-surface/70 p-1 xl:ml-8">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<button
|
||||||
|
className={`rounded-md px-3 py-2 text-sm font-semibold transition ${activeNavPath === item.path
|
||||||
|
? 'bg-text text-bg'
|
||||||
|
: 'text-text-soft hover:bg-fury-white'
|
||||||
|
}`}
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => navigate(item.path)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
className="rounded-md px-3 py-2 text-sm font-semibold text-text-soft transition hover:bg-fury-white"
|
||||||
|
href="https://sre.pawjob.us/"
|
||||||
|
>
|
||||||
|
SRE
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<form className="flex min-w-0 gap-2 xl:ml-auto xl:w-[360px]" onSubmit={handleTeamSearch}>
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-border bg-fury-white px-3 py-2 text-sm outline-none transition focus:border-ring focus:shadow-[0_0_0_3px_var(--color-shadow)]"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={teamQuery}
|
||||||
|
onChange={(event) => setTeamQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-fury-cyan px-4 py-2 text-sm font-semibold text-text transition hover:bg-fury-aqua"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mx-auto w-full max-w-7xl px-5 sm:px-8">
|
||||||
|
{route.page === 'home' ? (
|
||||||
|
<Landing live={live} matches={matches} navigate={navigate} />
|
||||||
|
) : null}
|
||||||
|
{route.page === 'teams' ? (
|
||||||
|
<TeamsPage leaderboard={leaderboard} navigate={navigate} teams={teams} />
|
||||||
|
) : null}
|
||||||
|
{route.page === 'team' ? (
|
||||||
|
<TeamProfilePage
|
||||||
|
navigate={navigate}
|
||||||
|
profile={profile}
|
||||||
|
requestedTeam={route.teamName}
|
||||||
|
teams={teams}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Landing({ live, matches, navigate }) {
|
||||||
|
const treeRef = useRef(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
||||||
|
<div className="relative min-h-[calc(100vh-74px)] overflow-hidden px-5 sm:px-8">
|
||||||
|
<PixelMountains />
|
||||||
|
|
||||||
|
<section className="relative z-10 mx-auto grid min-h-[calc(100vh-74px)] w-full max-w-7xl gap-8 pt-16 pb-10 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="text-base font-semibold uppercase tracking-wide text-fury-cyan">
|
||||||
|
BorisBot got nothin on THIS
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-3 text-6xl font-bold tracking-normal sm:text-7xl lg:text-8xl">
|
||||||
|
Toothless' TSS Bot
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-2xl text-xl leading-9 text-text-soft">
|
||||||
|
Powered by Spectra. TSS analytics.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-4">
|
||||||
|
<button
|
||||||
|
className="rounded-lg bg-text px-7 py-4 text-base font-semibold text-bg"
|
||||||
|
onClick={() => navigate('/teams')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Team leaderboard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border-2 border-ring px-7 py-4 text-base font-semibold text-fury-cyan"
|
||||||
|
onClick={() => navigate('/battle-logs')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Battle Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative min-h-[520px] overflow-hidden">
|
||||||
|
<FallingLeaves treeRef={treeRef} />
|
||||||
|
<div className="absolute inset-0 z-[1] flex items-end justify-center pb-10 lg:pb-0">
|
||||||
|
<Tree ref={treeRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecentGamesSection live={live} matches={matches} navigate={navigate} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentGamesSection({ live, matches, navigate }) {
|
||||||
|
const recentMatches = matches.slice(0, 6)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative z-20 border-t border-border bg-fury-white px-5 py-10 sm:px-8">
|
||||||
|
<div className="mx-auto w-full max-w-7xl">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||||
|
Recent activity
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-3xl font-bold">Latest games</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="w-fit rounded-lg border border-ring px-4 py-2 text-sm font-semibold text-fury-cyan transition hover:bg-surface"
|
||||||
|
onClick={() => navigate('/battle-logs')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
View Battle Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||||
|
{recentMatches.map((match) => (
|
||||||
|
<article
|
||||||
|
className="rounded-lg border border-border bg-bg p-4 shadow-sm"
|
||||||
|
key={match.session_id}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate text-lg font-semibold">
|
||||||
|
{match.map_name || 'Unknown map'}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-text-soft">{formatDate(match.endtime_unix)}</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-md bg-surface px-2 py-1 text-xs font-semibold text-text-soft">
|
||||||
|
{match.game_type || match.mode || 'SQB'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-3 text-sm">
|
||||||
|
<p className="truncate text-right font-semibold text-fury-cyan">
|
||||||
|
{match.winning_tag || match.winning_squadron || 'Winner'}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-semibold uppercase text-text-muted">vs</span>
|
||||||
|
<p className="truncate font-semibold text-fury-violet">
|
||||||
|
{match.losing_tag || match.losing_squadron || 'Loser'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!recentMatches.length ? (
|
||||||
|
<p className="mt-6 rounded-lg border border-border bg-bg px-5 py-6 text-sm text-text-soft">
|
||||||
|
{live.status === 'loading' ? 'Loading latest games' : live.error || 'No games returned'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PixelMountains() {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const WORLD_W = 1920
|
||||||
|
const WORLD_H = 900
|
||||||
|
|
||||||
|
function interpolate(points, x) {
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const [x0, y0] = points[i]
|
||||||
|
const [x1, y1] = points[i + 1]
|
||||||
|
if (x >= x0 && x <= x1) {
|
||||||
|
const t = (x - x0) / Math.max(1, x1 - x0)
|
||||||
|
return y0 + (y1 - y0) * t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return points.at(-1)[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMountain(points, color, jitter = 0) {
|
||||||
|
const width = WORLD_W
|
||||||
|
const height = WORLD_H
|
||||||
|
ctx.fillStyle = color
|
||||||
|
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const wave = jitter
|
||||||
|
? Math.sin(x * 0.08) * jitter + Math.sin(x * 0.021) * jitter * 1.8
|
||||||
|
: 0
|
||||||
|
const y = Math.round(interpolate(points, x) + wave)
|
||||||
|
ctx.fillRect(x, y, 1, height - y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const width = WORLD_W
|
||||||
|
const height = WORLD_H
|
||||||
|
|
||||||
|
canvas.width = WORLD_W
|
||||||
|
canvas.height = WORLD_H
|
||||||
|
ctx.imageSmoothingEnabled = false
|
||||||
|
ctx.clearRect(0, 0, WORLD_W, WORLD_H)
|
||||||
|
|
||||||
|
drawMountain(
|
||||||
|
[
|
||||||
|
[0, height * 0.82],
|
||||||
|
[width * 0.12, height * 0.73],
|
||||||
|
[width * 0.26, height * 0.66],
|
||||||
|
[width * 0.39, height * 0.76],
|
||||||
|
[width * 0.55, height * 0.58],
|
||||||
|
[width * 0.64, height * 0.46],
|
||||||
|
[width * 0.73, height * 0.62],
|
||||||
|
[width * 0.86, height * 0.56],
|
||||||
|
[width, height * 0.64],
|
||||||
|
],
|
||||||
|
'#fcfbcf',
|
||||||
|
1.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
drawMountain(
|
||||||
|
[
|
||||||
|
[0, height * 0.86],
|
||||||
|
[width * 0.15, height * 0.78],
|
||||||
|
[width * 0.31, height * 0.81],
|
||||||
|
[width * 0.43, height * 0.69],
|
||||||
|
[width * 0.52, height * 0.76],
|
||||||
|
[width * 0.62, height * 0.43],
|
||||||
|
[width * 0.69, height * 0.31],
|
||||||
|
[width * 0.78, height * 0.48],
|
||||||
|
[width, height * 0.5],
|
||||||
|
],
|
||||||
|
'#fff2e6',
|
||||||
|
1.6,
|
||||||
|
)
|
||||||
|
|
||||||
|
drawMountain(
|
||||||
|
[
|
||||||
|
[0, height * 0.94],
|
||||||
|
[width * 0.17, height * 0.9],
|
||||||
|
[width * 0.32, height * 0.92],
|
||||||
|
[width * 0.47, height * 0.83],
|
||||||
|
[width * 0.58, height * 0.89],
|
||||||
|
[width * 0.66, height * 0.61],
|
||||||
|
[width * 0.71, height * 0.5],
|
||||||
|
[width * 0.84, height * 0.7],
|
||||||
|
[width, height * 0.68],
|
||||||
|
],
|
||||||
|
'#fee5cd',
|
||||||
|
2.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
drawMountain(
|
||||||
|
[
|
||||||
|
[0, height * 0.98],
|
||||||
|
[width * 0.17, height * 0.96],
|
||||||
|
[width * 0.34, height * 0.99],
|
||||||
|
[width * 0.48, height * 0.93],
|
||||||
|
[width * 0.62, height * 0.97],
|
||||||
|
[width * 0.72, height * 0.88],
|
||||||
|
[width * 0.86, height * 0.95],
|
||||||
|
[width, height * 0.93],
|
||||||
|
],
|
||||||
|
'#fdca9b',
|
||||||
|
1.4,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
draw()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="pixel-mountains" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamsPage({ leaderboard, navigate, teams }) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Team leaderboard</h1>
|
||||||
|
<p className="mt-2 text-sm text-text-soft">
|
||||||
|
{leaderboard.status === 'loading'
|
||||||
|
? 'Loading leaderboard'
|
||||||
|
: leaderboard.error || `${teams.length} teams returned`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
|
{teams.map((team, index) => {
|
||||||
|
const name = bestTeamName(team)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left hover:bg-surface md:grid-cols-[4rem_1fr_repeat(4,auto)] md:items-center"
|
||||||
|
key={`${name}-${team.clan_id || index}`}
|
||||||
|
onClick={() => navigate(teamPath(name))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-fury-cyan">#{index + 1}</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-lg font-semibold">{name}</span>
|
||||||
|
<span className="block truncate text-xs text-text-soft">
|
||||||
|
{team.long_name || team.short_name || 'Unresolved'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">{formatNumber(team.player_count)} players</span>
|
||||||
|
<span className="text-sm">{formatNumber(team.total_battles)} battles</span>
|
||||||
|
<span className="text-sm">{Number(team.win_rate || 0).toFixed(1)}% WR</span>
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{formatNumber(team.points?.total_points || team.total_kills)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!teams.length ? (
|
||||||
|
<p className="px-5 py-10 text-sm text-text-soft">
|
||||||
|
{leaderboard.status === 'loading' ? 'Loading leaderboard' : 'No teams returned'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
||||||
|
const detail = profile.detail.data
|
||||||
|
const summary = detail?.team_summary || detail?.squadron_summary
|
||||||
|
const players = detail?.players || []
|
||||||
|
const games = profile.games.data?.games || []
|
||||||
|
const history = profile.history.data?.history || []
|
||||||
|
const ratingHourly = profile.history.data?.rating_hourly || []
|
||||||
|
const latestRating = ratingHourly.at(-1)?.rating || summary?.points?.total_points
|
||||||
|
const leaderboardTeam = teams.find((team) => bestTeamName(team) === requestedTeam)
|
||||||
|
const displayName = detail?.tag_name || bestTeamName(leaderboardTeam) || requestedTeam
|
||||||
|
const longName = detail?.long_name || leaderboardTeam?.long_name || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<button
|
||||||
|
className="text-sm font-semibold text-fury-cyan hover:text-text"
|
||||||
|
onClick={() => navigate('/teams')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Back to leaderboard
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||||
|
Team profile
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-4xl font-bold">{displayName}</h1>
|
||||||
|
<p className="mt-2 text-sm text-text-soft">
|
||||||
|
{profile.detail.error || longName || profile.detail.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
|
||||||
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||||
|
Rating {formatNumber(latestRating)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||||
|
Clan {detail?.clan_id || leaderboardTeam?.clan_id || 'n/a'}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-md bg-surface px-3 py-2 font-semibold">
|
||||||
|
{detail?.data_set || 'tss'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<Stat label="Roster" value={formatNumber(summary?.player_count)} />
|
||||||
|
<Stat label="Battles" value={formatNumber(summary?.total_battles)} />
|
||||||
|
<Stat label="Wins" value={formatNumber(summary?.wins)} />
|
||||||
|
<Stat label="Win rate" value={`${Number(summary?.win_rate || 0).toFixed(1)}%`} />
|
||||||
|
<Stat label="KDR" value={Number(summary?.kdr || 0).toFixed(1)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<RosterTable players={players} status={profile.detail.status} />
|
||||||
|
<RatingPanel history={history} ratingHourly={ratingHourly} status={profile.history.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BattleResults games={games} status={profile.games.status} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RosterTable({ players, status }) {
|
||||||
|
const sortedPlayers = [...players].sort((a, b) => {
|
||||||
|
return (b.total_kills || 0) - (a.total_kills || 0) || String(a.nick || '').localeCompare(b.nick || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
|
<div className="border-b border-surface px-5 py-4">
|
||||||
|
<h2 className="text-lg font-semibold">Roster</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">{formatNumber(sortedPlayers.length)} players</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[520px] overflow-auto">
|
||||||
|
{sortedPlayers.map((player) => (
|
||||||
|
<div
|
||||||
|
className="grid gap-3 border-b border-surface px-5 py-3 text-sm md:grid-cols-[1fr_repeat(5,auto)] md:items-center"
|
||||||
|
key={player.uid}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{player.nick || player.uid}</p>
|
||||||
|
<p className="text-xs text-text-soft">
|
||||||
|
{player.uid} · {formatNumber(player.points || player.sqb_points)} pts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p>{formatNumber(player.total_battles)} battles</p>
|
||||||
|
<p>{formatNumber(player.total_kills)} kills</p>
|
||||||
|
<p>{Number(player.win_rate || 0).toFixed(1)}% WR</p>
|
||||||
|
<p>{Number(player.kdr || 0).toFixed(1)} KDR</p>
|
||||||
|
<p>{formatNumber(player.assists)} assists</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!sortedPlayers.length ? (
|
||||||
|
<p className="px-5 py-10 text-sm text-text-soft">
|
||||||
|
{status === 'loading' ? 'Loading roster' : 'No roster rows returned'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingPanel({ history, ratingHourly, status }) {
|
||||||
|
const recentHistory = history.slice(-8)
|
||||||
|
const firstRating = ratingHourly[0]?.rating || 0
|
||||||
|
const latestRating = ratingHourly.at(-1)?.rating || 0
|
||||||
|
const ratingChange = latestRating - firstRating
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
|
<div className="border-b border-surface px-5 py-4">
|
||||||
|
<h2 className="text-lg font-semibold">History</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">
|
||||||
|
{ratingHourly.length ? `${formatNumber(ratingHourly.length)} rating snapshots` : status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-5 p-5">
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<Stat label="Latest rating" value={formatNumber(latestRating)} />
|
||||||
|
<Stat
|
||||||
|
label="Rating change"
|
||||||
|
value={`${ratingChange >= 0 ? '+' : ''}${formatNumber(ratingChange)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentHistory.map((item) => (
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-[1fr_auto_auto] gap-3 rounded-md bg-surface px-3 py-2 text-sm"
|
||||||
|
key={item.period}
|
||||||
|
>
|
||||||
|
<span className="font-semibold">{item.period}</span>
|
||||||
|
<span>{formatNumber(item.battles)} battles</span>
|
||||||
|
<span>{Number(item.win_rate || 0).toFixed(1)}% WR</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!recentHistory.length ? (
|
||||||
|
<p className="text-sm text-text-soft">
|
||||||
|
{status === 'loading' ? 'Loading history' : 'No history rows returned'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BattleResults({ games, status }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
|
<div className="border-b border-surface px-5 py-4">
|
||||||
|
<h2 className="text-lg font-semibold">Battle results</h2>
|
||||||
|
<p className="mt-1 text-sm text-text-soft">{formatNumber(games.length)} battles returned</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[560px] overflow-auto">
|
||||||
|
{games.map((game) => (
|
||||||
|
<div
|
||||||
|
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_auto_repeat(5,auto)] md:items-center"
|
||||||
|
key={game.session_id}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{game.map_name || 'Unknown map'}</p>
|
||||||
|
<p className="text-xs text-text-soft">
|
||||||
|
{formatDate(game.timestamp)} · {game.session_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`rounded-md px-3 py-1 text-sm font-semibold ${String(game.result).toLowerCase() === 'win'
|
||||||
|
? 'bg-surface text-fury-cyan'
|
||||||
|
: 'bg-fury-ice text-fury-violet'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{game.result || 'Unknown'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{formatNumber(game.player_count)} players</p>
|
||||||
|
<p className="text-sm">{formatNumber(game.stats?.ground_kills)} ground</p>
|
||||||
|
<p className="text-sm">{formatNumber(game.stats?.air_kills)} air</p>
|
||||||
|
<p className="text-sm">{formatNumber(game.stats?.assists)} assists</p>
|
||||||
|
<p className="text-sm">{formatNumber(game.stats?.deaths)} deaths</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!games.length ? (
|
||||||
|
<p className="px-5 py-10 text-sm text-text-soft">
|
||||||
|
{status === 'loading' ? 'Loading battle results' : 'No battle results returned'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BattleLogsPage({ live, matches }) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Battle Logs</h1>
|
||||||
|
<p className="mt-2 text-sm text-text-soft">
|
||||||
|
{live.status === 'loading' ? 'Loading battles' : live.error || `${matches.length} battles returned`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
|
{matches.map((match) => (
|
||||||
|
<div
|
||||||
|
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_1.2fr_auto]"
|
||||||
|
key={match.session_id}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold">{match.map_name || 'Unknown map'}</p>
|
||||||
|
<p className="text-xs text-text-soft">{formatDate(match.endtime_unix)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3 text-sm">
|
||||||
|
<p className="truncate text-right font-semibold text-fury-cyan">
|
||||||
|
{match.winning_tag || match.winning_squadron || 'Winner'}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-semibold uppercase text-text-muted">vs</span>
|
||||||
|
<p className="truncate font-semibold text-fury-violet">
|
||||||
|
{match.losing_tag || match.losing_squadron || 'Loser'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-soft">{match.game_type || match.mode || 'SQB'}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!matches.length ? (
|
||||||
|
<p className="px-5 py-10 text-sm text-text-soft">
|
||||||
|
{live.status === 'loading' ? 'Loading battles' : 'No battles returned'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './styles.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-cerulean-50: #e5f9ff;
|
||||||
|
--color-cerulean-100: #ccf3ff;
|
||||||
|
--color-cerulean-200: #99e7ff;
|
||||||
|
--color-cerulean-300: #66dbff;
|
||||||
|
--color-cerulean-400: #33cfff;
|
||||||
|
--color-cerulean-500: #00c3ff;
|
||||||
|
--color-cerulean-600: #009ccc;
|
||||||
|
--color-cerulean-700: #007599;
|
||||||
|
--color-cerulean-800: #004e66;
|
||||||
|
--color-cerulean-900: #002733;
|
||||||
|
--color-cerulean-950: #001b24;
|
||||||
|
|
||||||
|
--color-tropical-teal-50: #e5feff;
|
||||||
|
--color-tropical-teal-100: #ccfcff;
|
||||||
|
--color-tropical-teal-200: #99faff;
|
||||||
|
--color-tropical-teal-300: #66f7ff;
|
||||||
|
--color-tropical-teal-400: #33f5ff;
|
||||||
|
--color-tropical-teal-500: #00f2ff;
|
||||||
|
--color-tropical-teal-600: #00c2cc;
|
||||||
|
--color-tropical-teal-700: #009199;
|
||||||
|
--color-tropical-teal-800: #006166;
|
||||||
|
--color-tropical-teal-900: #003033;
|
||||||
|
--color-tropical-teal-950: #002224;
|
||||||
|
|
||||||
|
--color-light-yellow-50: #fefde7;
|
||||||
|
--color-light-yellow-100: #fcfbcf;
|
||||||
|
--color-light-yellow-200: #f9f69f;
|
||||||
|
--color-light-yellow-300: #f7f26e;
|
||||||
|
--color-light-yellow-400: #f4ee3e;
|
||||||
|
--color-light-yellow-500: #f1e90e;
|
||||||
|
--color-light-yellow-600: #c1bb0b;
|
||||||
|
--color-light-yellow-700: #918c08;
|
||||||
|
--color-light-yellow-800: #605d06;
|
||||||
|
--color-light-yellow-900: #302f03;
|
||||||
|
--color-light-yellow-950: #222102;
|
||||||
|
|
||||||
|
--color-soft-apricot-50: #fff2e6;
|
||||||
|
--color-soft-apricot-100: #fee5cd;
|
||||||
|
--color-soft-apricot-200: #fdca9b;
|
||||||
|
--color-soft-apricot-300: #fdb068;
|
||||||
|
--color-soft-apricot-400: #fc9636;
|
||||||
|
--color-soft-apricot-500: #fb7b04;
|
||||||
|
--color-soft-apricot-600: #c96303;
|
||||||
|
--color-soft-apricot-700: #974a02;
|
||||||
|
--color-soft-apricot-800: #643102;
|
||||||
|
--color-soft-apricot-900: #321901;
|
||||||
|
--color-soft-apricot-950: #231101;
|
||||||
|
|
||||||
|
--color-vibrant-coral-50: #fde9e8;
|
||||||
|
--color-vibrant-coral-100: #fad3d1;
|
||||||
|
--color-vibrant-coral-200: #f6a8a2;
|
||||||
|
--color-vibrant-coral-300: #f17c74;
|
||||||
|
--color-vibrant-coral-400: #ed5145;
|
||||||
|
--color-vibrant-coral-500: #e82517;
|
||||||
|
--color-vibrant-coral-600: #ba1e12;
|
||||||
|
--color-vibrant-coral-700: #8b160e;
|
||||||
|
--color-vibrant-coral-800: #5d0f09;
|
||||||
|
--color-vibrant-coral-900: #2e0705;
|
||||||
|
--color-vibrant-coral-950: #200503;
|
||||||
|
|
||||||
|
--color-bg: #fefde7;
|
||||||
|
--color-surface: #fcfbcf;
|
||||||
|
--color-surface-alt: #f9f69f;
|
||||||
|
--color-fury-white: #fefde7;
|
||||||
|
--color-fury-ice: #fcfbcf;
|
||||||
|
--color-fury-blue: #f9f69f;
|
||||||
|
--color-fury-glow: #fdb068;
|
||||||
|
--color-fury-cyan: #e82517;
|
||||||
|
--color-fury-aqua: #ed5145;
|
||||||
|
--color-fury-violet: #fb7b04;
|
||||||
|
--color-text: #000000;
|
||||||
|
--color-text-soft: #555555;
|
||||||
|
--color-text-muted: #888888;
|
||||||
|
--color-border: #fee5cd;
|
||||||
|
--color-ring: #ed5145;
|
||||||
|
--color-shadow: rgba(232, 37, 23, 0.12);
|
||||||
|
--color-success: #00f2ff;
|
||||||
|
--color-warning: #f4ee3e;
|
||||||
|
--color-danger: #e82517;
|
||||||
|
}
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pixel-mountains {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center bottom;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree {
|
||||||
|
width: min(100%, 500px);
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
background: #fefde7;
|
||||||
|
border: 3px solid #fdca9b;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px rgba(253, 202, 155, 0.35),
|
||||||
|
0 1px 3px rgba(50, 25, 1, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.falling-leaves {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3001,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_TARGET ?? 'http://localhost:6000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/health': {
|
||||||
|
target: process.env.VITE_API_TARGET ?? 'http://localhost:6000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
const crypto = require('node:crypto')
|
||||||
|
const http = require('node:http')
|
||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
|
||||||
|
const PORT = Number(process.env.WEBHOOK_PORT || 3011)
|
||||||
|
const SECRET = process.env.GITHUB_WEBHOOK_SECRET || ''
|
||||||
|
const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web')
|
||||||
|
.split(',')
|
||||||
|
.map((target) => target.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const RESTART_AFTER_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
let deploying = false
|
||||||
|
|
||||||
|
function json(res, status, body) {
|
||||||
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||||
|
res.end(JSON.stringify(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySignature(rawBody, signature) {
|
||||||
|
if (!SECRET) return true
|
||||||
|
if (!signature || !signature.startsWith('sha256=')) return false
|
||||||
|
|
||||||
|
const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
|
||||||
|
const signatureBuffer = Buffer.from(signature)
|
||||||
|
const expectedBuffer = Buffer.from(expected)
|
||||||
|
|
||||||
|
return (
|
||||||
|
signatureBuffer.length === expectedBuffer.length &&
|
||||||
|
crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: __dirname,
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
if (code === 0) resolve()
|
||||||
|
else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deploy() {
|
||||||
|
await run('git', ['pull', '--ff-only'])
|
||||||
|
await run('npm', ['install'])
|
||||||
|
await run('npm', ['run', 'build'])
|
||||||
|
|
||||||
|
for (const target of RESTART_TARGETS) {
|
||||||
|
await run('pm2', ['reload', target, '--update-env'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http
|
||||||
|
.createServer((req, res) => {
|
||||||
|
if (req.method === 'GET' && req.url === '/health') {
|
||||||
|
json(res, 200, { ok: true, deploying, restart_targets: RESTART_TARGETS })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST' || req.url !== '/github') {
|
||||||
|
json(res, 404, { error: 'Not found' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = []
|
||||||
|
req.on('data', (chunk) => chunks.push(chunk))
|
||||||
|
req.on('end', () => {
|
||||||
|
const rawBody = Buffer.concat(chunks)
|
||||||
|
|
||||||
|
if (!verifySignature(rawBody, req.headers['x-hub-signature-256'])) {
|
||||||
|
json(res, 401, { error: 'Invalid signature' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.headers['x-github-event'] !== 'push') {
|
||||||
|
json(res, 202, { skipped: true, reason: 'Only push events trigger deploys' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deploying) {
|
||||||
|
json(res, 202, { queued: false, deploying: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deploying = true
|
||||||
|
json(res, 202, { accepted: true, restart_targets: RESTART_TARGETS })
|
||||||
|
|
||||||
|
deploy()
|
||||||
|
.then(() => console.log('GitHub push deploy completed'))
|
||||||
|
.catch((error) => console.error('GitHub push deploy failed:', error))
|
||||||
|
.finally(() => {
|
||||||
|
deploying = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`tssbot webhook listening on http://localhost:${PORT}/github`)
|
||||||
|
console.log(`restart targets: ${RESTART_TARGETS.join(', ')}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('24 hour webhook refresh reached; exiting for PM2 restart')
|
||||||
|
process.exit(0)
|
||||||
|
}, RESTART_AFTER_MS).unref()
|
||||||
Reference in New Issue
Block a user