Compare commits

...
10 Commits
Author SHA1 Message Date
warlock aca0c17e4d replay wip 2026-07-29 12:47:29 +05:30
warlock 6f7af751e1 account system and syslogs 2026-07-26 18:31:43 +00:00
warlock 540f6e0af1 tweaks 2026-07-13 13:55:56 +00:00
NextJS e324a95625 sync 2026-07-12 17:55:44 +00:00
NextJS 4ed4a10eae ip geolocation + ping analysis 2026-06-02 10:24:58 +00:00
NextJS dc8377177a analysis complete 2026-05-23 15:40:53 +00:00
NextJS 975cc5f1d9 analysis complete 2026-05-23 15:03:50 +00:00
warlock 9bb0af59b0 analysis WiP 2026-05-23 20:07:14 +05:30
warlock 0bb486223e add supply 2026-05-13 12:59:35 +05:30
warlock fcf7654e78 fixed matches fee calc 2026-05-12 16:32:05 +05:30
73 changed files with 8114 additions and 603 deletions
+19
View File
@@ -1,3 +1,18 @@
# Admin panel bootstrap (server only). Used once to create the admin account
# in data/admin-accounts.json. After bootstrap, change the password in Settings.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-to-a-strong-password
# Optional: HMAC secret for signed session cookies. Defaults to a value derived
# from ADMIN_PASSWORD when unset.
# ADMIN_SESSION_SECRET=
# Optional: absolute path for the accounts JSON file (default: <cwd>/data/admin-accounts.json)
# ADMIN_ACCOUNTS_PATH=
# Optional: absolute path for the panel audit log (default: <cwd>/data/admin-audit.jsonl)
# ADMIN_AUDIT_LOG_PATH=
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
# Example: https://kickkings.playpoolstudios.com
# APP_ORIGIN=
@@ -16,3 +31,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Directory containing matchmaker logs: history.log (processed) and matchmaker.log (raw).
# Example: /var/www/html/kickkings/logs/matchmaker/logs
# MATCHMAKER_LOGS_DIR=
# Third-party IP geolocation provider (ipgeolocation.io)
# Used for country code lookup from user IP and ping report IP.
IPGEOLOCATION_API_KEY=your-ipgeolocation-api-key
+5
View File
@@ -39,3 +39,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# admin panel accounts (passwords hashed; still keep private)
/data/admin-accounts.json
/data/admin-audit.jsonl
/data/*.tmp
+1
View File
@@ -0,0 +1 @@
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=KickKings Admin (Next.js)
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=next
Group=next
WorkingDirectory=/home/next/kickkings_admin
Environment=NODE_ENV=production
Environment=PATH=/home/next/.nvm/versions/node/v24.13.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/home/next/.nvm/versions/node/v24.13.0/bin/npm run start
Restart=on-failure
RestartSec=5
# Hardening (optional; remove if the service fails to start)
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+1 -3
View File
@@ -1,7 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
const nextConfig: NextConfig = {};
export default nextConfig;
+363 -190
View File
@@ -9,15 +9,17 @@
"version": "0.1.0",
"dependencies": {
"@supabase/supabase-js": "^2.102.1",
"next": "^16.2.4",
"next": "^16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-simple-maps": "^3.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-simple-maps": "^3.0.6",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
@@ -38,13 +40,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -53,9 +55,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -63,22 +65,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -95,14 +96,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -112,14 +113,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -129,9 +130,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -139,29 +140,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -171,9 +172,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -181,9 +182,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -191,9 +192,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -201,27 +202,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -231,33 +232,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -265,14 +266,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1037,9 +1038,9 @@
}
},
"node_modules/@next/env": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1053,9 +1054,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
"cpu": [
"arm64"
],
@@ -1069,9 +1070,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
"cpu": [
"x64"
],
@@ -1085,9 +1086,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
"cpu": [
"arm64"
],
@@ -1101,9 +1102,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
"cpu": [
"arm64"
],
@@ -1117,9 +1118,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
"cpu": [
"x64"
],
@@ -1133,9 +1134,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
"cpu": [
"x64"
],
@@ -1149,9 +1150,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
"cpu": [
"arm64"
],
@@ -1165,9 +1166,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
"cpu": [
"x64"
],
@@ -1612,6 +1613,51 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-color": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
"integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
"integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-color": "^2"
}
},
"node_modules/@types/d3-selection": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz",
"integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-zoom": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz",
"integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "^2",
"@types/d3-selection": "^2"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1619,6 +1665,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1648,7 +1701,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1663,6 +1715,19 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-simple-maps": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-geo": "^2",
"@types/d3-zoom": "^2",
"@types/geojson": "*",
"@types/react": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -1717,7 +1782,6 @@
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
@@ -1872,9 +1936,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2243,7 +2307,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2587,7 +2650,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -2725,6 +2787,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2761,6 +2829,105 @@
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"license": "BSD-3-Clause",
"dependencies": {
"internmap": "^1.0.0"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-drag": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-selection": "2"
}
},
"node_modules/d3-ease": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
"license": "BSD-3-Clause"
},
"node_modules/d3-geo": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^2.5.0"
}
},
"node_modules/d3-interpolate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-color": "1 - 2"
}
},
"node_modules/d3-selection": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-transition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-color": "1 - 2",
"d3-dispatch": "1 - 2",
"d3-ease": "1 - 2",
"d3-interpolate": "1 - 2",
"d3-timer": "1 - 2"
},
"peerDependencies": {
"d3-selection": "2"
}
},
"node_modules/d3-zoom": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-drag": "2",
"d3-interpolate": "1 - 2",
"d3-selection": "2",
"d3-transition": "2"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -3156,7 +3323,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3342,7 +3508,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3975,9 +4140,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4065,6 +4230,12 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
"license": "ISC"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4526,14 +4697,23 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -4937,7 +5117,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -5031,9 +5210,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -5072,12 +5251,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.6",
"@next/env": "16.2.10",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -5091,14 +5270,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.6",
"@next/swc-darwin-x64": "16.2.6",
"@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-arm64-musl": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.6",
"@next/swc-linux-x64-musl": "16.2.6",
"@next/swc-win32-arm64-msvc": "16.2.6",
"@next/swc-win32-x64-msvc": "16.2.6",
"@next/swc-darwin-arm64": "16.2.10",
"@next/swc-darwin-x64": "16.2.10",
"@next/swc-linux-arm64-gnu": "16.2.10",
"@next/swc-linux-arm64-musl": "16.2.10",
"@next/swc-linux-x64-gnu": "16.2.10",
"@next/swc-linux-x64-musl": "16.2.10",
"@next/swc-win32-arm64-msvc": "16.2.10",
"@next/swc-win32-x64-msvc": "16.2.10",
"sharp": "^0.34.5"
},
"peerDependencies": {
@@ -5124,34 +5303,6 @@
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-exports-info": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -5182,7 +5333,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5439,10 +5589,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"funding": [
{
"type": "opencollective",
@@ -5459,7 +5608,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -5481,7 +5630,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -5525,7 +5673,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5535,7 +5682,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5547,9 +5693,25 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-simple-maps": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
"license": "MIT",
"dependencies": {
"d3-geo": "^2.0.2",
"d3-selection": "^2.0.0",
"d3-zoom": "^2.0.0",
"topojson-client": "^3.1.0"
},
"peerDependencies": {
"prop-types": "^15.7.2",
"react": "^16.8.0 || 17.x || 18.x",
"react-dom": "^16.8.0 || 17.x || 18.x"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5839,9 +6001,9 @@
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
@@ -6227,7 +6389,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6248,6 +6409,20 @@
"node": ">=8.0"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -6390,7 +6565,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6640,9 +6814,9 @@
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -6686,7 +6860,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+8 -2
View File
@@ -10,18 +10,24 @@
},
"dependencies": {
"@supabase/supabase-js": "^2.102.1",
"next": "^16.2.4",
"next": "^16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"react-simple-maps": "^3.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-simple-maps": "^3.0.6",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
"typescript": "^5"
},
"overrides": {
"d3-color": "^3.1.0",
"postcss": "^8.5.10"
}
}
+11
View File
@@ -0,0 +1,11 @@
create table public.ping_reports (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
user_id bigint not null,
match_id bigint not null,
ip_address text null,
ping integer not null,
constraint ping_reports_pkey primary key (id),
constraint ping_reports_user_id_fkey foreign key (user_id) references users (id),
constraint ping_reports_match_id_fkey foreign key (match_id) references matches (id)
) TABLESPACE pg_default;
+291
View File
@@ -0,0 +1,291 @@
# Match Replay JSON Format
Guide for implementing the admin-panel replay viewer. Files are written by the Unity dedicated server (and practice host) to disk only.
## Where files live
```
{ApplicationDirectory}/Logs/{matchId}_replay.json
```
| Match type | `matchId` in filename / JSON | Notes |
|------------|------------------------------|--------|
| Ranked / dedicated | Positive matchmaker id (e.g. `228`) | Same id as `Logs/228.txt` |
| Practice / tutorial | Negative unix timestamp (e.g. `-1769539200`) | `isPractice: true`; unique per session |
Example paths:
- `Logs/228_replay.json`
- `Logs/-1769539200_replay.json`
## Top-level object
```json
{
"version": 1,
"matchId": 228,
"isPractice": false,
"fixedDeltaTime": 0.02,
"duration": 184.32,
"entities": [ /* ... */ ],
"frames": [ /* ... */ ],
"events": [ /* ... */ ]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `version` | int | Schema version. Currently `1`. |
| `matchId` | int | Match id (positive = ranked, negative = practice). |
| `isPractice` | bool | `true` for localhost practice / tutorial host. |
| `fixedDeltaTime` | float | Physics step used while recording (typically `0.02` → ~50 Hz). |
| `duration` | float | Length of the recording in seconds (`t` of last sample / end). |
| `entities` | array | Ball + pucks. Order defines pose array indices in every frame. |
| `frames` | array | Time-ordered pose samples (~every FixedUpdate). |
| `events` | array | Time-ordered discrete events (hits, goals, resets). |
Time `t` everywhere is **seconds since recording start** (match start), not wall-clock UTC.
---
## Entities
```json
{ "id": 0, "type": "ball", "team": "" }
{ "id": 1, "type": "puck", "team": "Red" }
{ "id": 2, "type": "puck", "team": "Blue" }
```
| Field | Type | Values |
|-------|------|--------|
| `id` | int | Stable index `0 .. N-1`. Matches array index in each frames `x`/`y`/`vx`/`vy`. |
| `type` | string | `"ball"` or `"puck"` |
| `team` | string | `"Red"`, `"Blue"`, or `""` for ball |
Entity list is fixed for the whole file. **Index `i` in a frames pose arrays always refers to `entities[i]`.**
Typical order: ball first, then pucks sorted by team then spawn position.
---
## Frames (motion samples)
```json
{
"t": 12.40,
"x": [0.0, 1.2, -0.5],
"y": [0.1, -0.3, 2.0],
"vx": [0.0, 3.1, -1.0],
"vy": [0.2, 0.0, 0.5]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `t` | float | Sample time in seconds (monotonic, increasing). |
| `x`, `y` | float[] | World position per entity (Unity 2D world units). |
| `vx`, `vy` | float[] | Linear velocity per entity at that sample (world units / second). |
- Array length always equals `entities.length`.
- Samples are dense (~`1 / fixedDeltaTime` Hz). Do **not** treat them as render frames.
### Coordinate system
Unity 2D world space:
- Origin at field center
- `+X` right, `+Y` up (as in Unity)
- Scale matches the in-game pitch (see game `fieldSize` / art for admin canvas mapping)
---
## Events
```json
{ "t": 12.4, "type": "hit", "kind": "ball_wall", "vol": 0.6, "id": 0, "team": "", "redScore": 0, "blueScore": 0, "kickoff": false }
{ "t": 30.1, "type": "goal", "kind": "", "vol": 0, "id": -1, "team": "Blue", "redScore": 1, "blueScore": 0, "kickoff": false }
{ "t": 32.2, "type": "reset", "kind": "", "vol": 0, "id": -1, "team": "", "redScore": 0, "blueScore": 0, "kickoff": false }
```
All event objects share the same fields (Unity `JsonUtility` shape). Unused fields are empty / zero / `-1`.
| Field | Used by | Description |
|-------|---------|-------------|
| `t` | all | Event time in seconds. |
| `type` | all | `"hit"` \| `"goal"` \| `"reset"` |
| `kind` | `hit` | `"puck_puck"` \| `"ball_puck"` \| `"ball_wall"` |
| `vol` | `hit` | SFX volume `0..1` (from relative impact speed). |
| `id` | `hit` | Entity id involved (spatial cue). `-1` if unknown. |
| `team` | `goal` | **Conceding** side / goal owner (`"Red"` or `"Blue"`). The other team scored. |
| `redScore` | `goal` | Score after this goal. |
| `blueScore` | `goal` | Score after this goal. |
| `kickoff` | `goal`, `reset` | Kickoff-goal / kickoff-style reset when `true`. |
### Event semantics
| `type` | Meaning for the viewer |
|--------|-------------------------|
| `hit` | Play collision SFX at `vol`. Optional: flash near entity `id`. |
| `goal` | Goal scored against `team`. Update scoreboard to `redScore`/`blueScore`. Play goal SFX. **Discontinuity** — do not interpolate motion across this time. |
| `reset` | Pieces are lerping / teleporting back. **Discontinuity** — snap poses; do not blend across. |
First-to-3 wins; a completed match usually ends shortly after a goal that makes a score `3`.
---
## Playback contract (required for smooth motion)
Recording is FixedUpdate (~50 Hz). Snapping happens if the viewer steps sample-by-sample without blending. Follow this:
### 1. Drive time with wall clock
```text
playbackTime += (deltaMs / 1000) * playbackSpeed
playbackTime = clamp(playbackTime, 0, duration)
```
Use `requestAnimationFrame` (or equivalent). Do **not** advance one JSON frame per rAF tick.
### 2. Interpolate poses between samples
Binary-search `frames` for neighbors where:
```text
frames[i].t <= playbackTime < frames[i+1].t
```
Then for each entity index `e`:
**Minimum (good):** linear lerp of `x`/`y`:
```text
u = (playbackTime - t0) / (t1 - t0)
x = lerp(x0[e], x1[e], u)
y = lerp(y0[e], y1[e], u)
```
**Recommended (smooth under acceleration):** cubic Hermite using velocities:
```text
dt = t1 - t0
u = (playbackTime - t0) / dt
u2 = u*u
u3 = u2*u
h00 = 2*u3 - 3*u2 + 1
h10 = u3 - 2*u2 + u
h01 = -2*u3 + 3*u2
h11 = u3 - u2
x = h00*x0 + h10*dt*vx0 + h01*x1 + h11*dt*vx1
y = h00*y0 + h10*dt*vy0 + h01*y1 + h11*dt*vy1
```
(Same for each entity index.)
### 3. Snap across discontinuities
Treat every `goal` and `reset` event as a cut:
- When `playbackTime` crosses that events `t`, **do not** interpolate from the previous sample across the cut.
- Seek to the frame at or immediately after `t` and snap positions.
Practical approach: build a sorted list of discontinuity times from events where `type === "goal" || type === "reset"`. If `t0` and `t1` straddle a discontinuity, snap to the post-cut sample instead of lerping.
### 4. Fire events once when crossed
Keep `lastEventIndex` (or last fired `t`). When `playbackTime` advances past an events `t`, fire it once:
- `hit` → SFX by `kind` / `vol`
- `goal` → score UI + goal audio
- `reset` → optional reset cue (usually silent)
On seek / scrub: reset event cursor; either skip SFX or replay only from the new time forward.
---
## Suggested viewer pipeline
```text
Load JSON
→ map entities to sprites (ball / red pucks / blue pucks)
→ sort events by t (already ordered, but verify)
→ each animation frame:
advance playbackTime
resolve poses (Hermite + discontinuity snaps)
draw entities
dispatch newly crossed events
```
### Seeking / scrubbing
1. Set `playbackTime` to the scrub value.
2. Find the nearest frame (or interpolate as above).
3. Recompute score from the last `goal` event with `t <= playbackTime` (else `00`).
4. Reset the event-fire cursor to that time so SFX dont spam.
### Scoreboard without scrubbing
Start at `00`. On each `goal` event, set scores from that events `redScore` / `blueScore`.
---
## Minimal TypeScript types
```ts
export interface ReplayFile {
version: number;
matchId: number;
isPractice: boolean;
fixedDeltaTime: number;
duration: number;
entities: ReplayEntity[];
frames: ReplayFrame[];
events: ReplayEvent[];
}
export interface ReplayEntity {
id: number;
type: "ball" | "puck" | string;
team: "Red" | "Blue" | "" | string;
}
export interface ReplayFrame {
t: number;
x: number[];
y: number[];
vx: number[];
vy: number[];
}
export interface ReplayEvent {
t: number;
type: "hit" | "goal" | "reset" | string;
kind: "puck_puck" | "ball_puck" | "ball_wall" | "" | string;
vol: number;
id: number; // entity id, or -1
team: "Red" | "Blue" | "" | string; // conceding team on goal
redScore: number;
blueScore: number;
kickoff: boolean;
}
```
---
## Checklist for a correct viewer
- [ ] Time driven by real elapsed time × speed, not frame index
- [ ] Poses interpolated between samples (Hermite preferred)
- [ ] No interpolation across `goal` / `reset`
- [ ] Entity index `i` always matches `entities[i]`
- [ ] Hit SFX use `kind` + `vol`
- [ ] Goal `team` means **conceding** side
- [ ] Practice files filtered via `isPractice` or negative `matchId`
## Out of scope (not in the JSON)
- Player names / user ids (use matchmaker / match API by `matchId` for ranked games)
- Camera / UI layout
- Launch aim lines / turn timer
- Network latency or client prediction
+2
View File
@@ -2,7 +2,9 @@ create table public.users (
id bigint generated by default as identity not null,
created_at timestamp with time zone not null default now(),
username text null,
email text null,
password text null,
ip_address text null,
cc real null default '0'::real,
rc real null default '0'::real,
last_logged_at timestamp with time zone null default now(),
+161
View File
@@ -0,0 +1,161 @@
"use server";
import { redirect } from "next/navigation";
import {
createAccount,
deleteAccount,
findAccountById,
updateAccount,
} from "@/lib/auth/accounts-store";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
normalizePagePermissions,
PAGE_KEYS,
type PageKey,
type PagePermissions,
} from "@/lib/auth/permissions";
import { requireAdmin } from "@/lib/auth/require-session";
function parsePermissionsFromForm(formData: FormData): PagePermissions {
const raw: Partial<Record<PageKey, { read: boolean; write: boolean }>> = {};
for (const key of PAGE_KEYS) {
raw[key] = {
read: formData.get(`perm_${key}_read`) === "1",
write: formData.get(`perm_${key}_write`) === "1",
};
}
return normalizePagePermissions(raw);
}
export async function createAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
const permissions = parsePermissionsFromForm(formData);
if (!username) {
redirect("/settings?accountError=missingUsername");
}
if (!password) {
redirect("/settings?accountError=missingPassword");
}
const result = await createAccount({ username, password, permissions });
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.create",
summary: `Created account “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
permissions: result.account.permissions,
},
});
redirect("/settings?accountOk=created");
}
export async function updateAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
if (!id) {
redirect("/settings?accountError=notFound");
}
const existing = await findAccountById(id);
if (!existing) {
redirect("/settings?accountError=notFound");
}
const isAdminRow = formData.get("isAdmin") === "1";
const password = String(formData.get("password") ?? "");
const usernameRaw = String(formData.get("username") ?? "");
if (isAdminRow) {
if (!password) {
redirect("/settings?accountError=missingPassword");
}
const result = await updateAccount({ id, password });
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.update",
summary: `Changed password for admin “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
passwordChanged: true,
},
});
redirect("/settings?accountOk=updated");
}
const permissions = parsePermissionsFromForm(formData);
const result = await updateAccount({
id,
username: usernameRaw,
password: password || undefined,
permissions,
});
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.update",
summary: `Updated account “${result.account.username}`,
details: {
targetId: result.account.id,
targetUsername: result.account.username,
previousUsername: existing.username,
passwordChanged: Boolean(password),
permissions: result.account.permissions,
},
});
redirect("/settings?accountOk=updated");
}
export async function deleteAdminAccount(formData: FormData) {
const actor = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
if (!id) {
redirect("/settings?accountError=notFound");
}
const existing = await findAccountById(id);
if (!existing) {
redirect("/settings?accountError=notFound");
}
const result = await deleteAccount(id);
if (!result.ok) {
redirect(`/settings?accountError=${result.error}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "accounts.delete",
summary: `Deleted account “${existing.username}`,
details: {
targetId: existing.id,
targetUsername: existing.username,
},
});
redirect("/settings?accountOk=deleted");
}
+167
View File
@@ -0,0 +1,167 @@
"use server";
import { redirect } from "next/navigation";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { requirePageWrite } from "@/lib/auth/require-session";
import { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc";
import {
buildDashboardHref,
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import { createAdminSupabase } from "@/lib/supabase/admin";
import { normalizeLedgerPageSize } from "@/lib/ledger-table-view";
const SYSTEM_ACCOUNT_ID = 1;
function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!/^[1-9]\d*$/.test(s)) return null;
try {
return BigInt(s);
} catch {
return null;
}
}
export async function addSystemSupply(formData: FormData) {
const actor = await requirePageWrite("ledger");
const tabRaw = String(formData.get("tab") ?? "");
const tab: AdminDashboardTab =
tabRaw === "matches"
? "matches"
: tabRaw === "players"
? "players"
: tabRaw === "matchmaker"
? "matchmaker"
: tabRaw === "ledger"
? "ledger"
: tabRaw === "analysis"
? "analysis"
: "dashboard";
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
const highlightId = highlightRaw === "" ? null : highlightRaw;
const participant =
participantRaw === "" ? null : participantRaw;
const ledgerFromRaw = String(formData.get("ledgerFrom") ?? "").trim();
const ledgerToRaw = String(formData.get("ledgerTo") ?? "").trim();
const ledgerPageRaw = String(formData.get("ledgerPage") ?? "").trim();
const ledgerPageSizeRaw = String(
formData.get("ledgerPageSize") ?? "",
).trim();
const ledgerSortRaw = String(formData.get("ledgerSort") ?? "").trim();
const ledgerOrderRaw = String(formData.get("ledgerOrder") ?? "").trim();
const ledgerPageNum =
ledgerPageRaw === "" ? NaN : Number.parseInt(ledgerPageRaw, 10);
const base = {
tab,
highlightId,
participantRaw: participant,
...(tab === "ledger"
? {
ledgerFrom: ledgerFromRaw || null,
ledgerTo: ledgerToRaw || null,
ledgerPage:
Number.isInteger(ledgerPageNum) && ledgerPageNum > 1
? ledgerPageNum
: null,
ledgerSort: ledgerSortRaw || null,
ledgerOrder:
ledgerOrderRaw === "desc"
? ("desc" as const)
: ledgerOrderRaw === "asc"
? ("asc" as const)
: null,
ledgerPageSize: normalizeLedgerPageSize(
ledgerPageSizeRaw === "" ? null : ledgerPageSizeRaw,
),
}
: {}),
};
const parsedCoins = parsePositiveCoins(formData.get("supplyAmount"));
if (parsedCoins == null) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
const coins = parsedCoins;
const supabase = createAdminSupabase();
if (!supabase) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
const { data: sysRow, error: fetchErr } = await supabase
.from("users")
.select("id, rc")
.eq("id", SYSTEM_ACCOUNT_ID)
.maybeSingle();
if (fetchErr || !sysRow) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
const currentRc = sysRow.rc as number | null;
const { error: insertErr } = await supabase.from("transactions").insert({
from: null,
to: SYSTEM_ACCOUNT_ID,
amount: coins.toString(),
remarks: "supply",
match_id: null,
});
if (insertErr) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
const newRc = applyCoinsDeltaToRcBalance(currentRc, coins);
const { error: updateErr } = await supabase
.from("users")
.update({ rc: newRc })
.eq("id", SYSTEM_ACCOUNT_ID);
if (updateErr) {
redirect(
buildDashboardHref({
...base,
supplyError: true,
}),
);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "ledger.system_supply",
summary: `Added system supply of ${coins.toString()} coins`,
details: { coins: coins.toString(), systemAccountId: SYSTEM_ACCOUNT_ID },
});
redirect(buildDashboardHref(base));
}
+62 -18
View File
@@ -1,32 +1,34 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { requireAdmin } from "@/lib/auth/require-session";
import { parseStoredCoins } from "@/lib/coins-rc";
import { createAdminSupabase } from "@/lib/supabase/admin";
function normalizeValue(raw: FormDataEntryValue | null): string | null {
if (raw == null) return null;
const s = String(raw).trim();
return s === "" ? null : s;
const EMPTY = "__empty__";
const INVALID = "__invalid__";
function normalizeValue(raw: FormDataEntryValue | null): string | typeof EMPTY {
const s = raw == null ? "" : String(raw).trim();
return s === "" ? EMPTY : s;
}
function normalizeSettingValue(
key: string,
raw: FormDataEntryValue | null,
): string | null {
): string | typeof EMPTY | typeof INVALID {
const s = raw == null ? "" : String(raw).trim();
if (key === "entry_fee") {
if (s === "") return null;
if (s === "") return EMPTY;
const coins = parseStoredCoins(s);
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
return String(coins);
}
if (key === "bet_fee") {
if (s === "") return null;
if (s === "") return EMPTY;
const n = Math.round(Number(s));
if (!Number.isFinite(n)) return "__invalid__";
return String(Math.min(100, Math.max(0, n)));
@@ -36,10 +38,7 @@ function normalizeSettingValue(
}
export async function updateSetting(formData: FormData) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const actor = await requireAdmin();
const key = String(formData.get("key") ?? "").trim();
if (!key) {
@@ -47,7 +46,7 @@ export async function updateSetting(formData: FormData) {
}
const value = normalizeSettingValue(key, formData.get("value"));
if (value === "__invalid__") {
if (value === INVALID || value === EMPTY) {
redirect("/settings?saveError=1");
}
@@ -65,14 +64,19 @@ export async function updateSetting(formData: FormData) {
redirect("/settings?saveError=1");
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.update",
summary: `Updated setting “${key}`,
details: { key, value },
});
redirect("/settings");
}
export async function insertSetting(formData: FormData) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const actor = await requireAdmin();
const key = String(formData.get("newKey") ?? "").trim();
const value = normalizeValue(formData.get("newValue"));
@@ -80,6 +84,9 @@ export async function insertSetting(formData: FormData) {
if (!key) {
redirect("/settings?addError=missing");
}
if (value === EMPTY) {
redirect("/settings?addError=missingValue");
}
const supabase = createAdminSupabase();
if (!supabase) {
@@ -93,5 +100,42 @@ export async function insertSetting(formData: FormData) {
redirect(`/settings?addError=${code}`);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.insert",
summary: `Created setting “${key}`,
details: { key, value },
});
redirect("/settings");
}
export async function deleteSetting(formData: FormData) {
const actor = await requireAdmin();
const key = String(formData.get("key") ?? "").trim();
if (!key) {
redirect("/settings?saveError=1");
}
const supabase = createAdminSupabase();
if (!supabase) {
redirect("/settings?saveError=1");
}
const { error } = await supabase.from("settings").delete().eq("key", key);
if (error) {
redirect("/settings?saveError=1");
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "settings.delete",
summary: `Deleted setting “${key}`,
details: { key },
});
redirect("/settings");
}
+13 -6
View File
@@ -1,8 +1,8 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { requirePageWrite } from "@/lib/auth/require-session";
import {
buildDashboardHref,
type AdminDashboardTab,
@@ -19,10 +19,7 @@ function parseScoreField(raw: FormDataEntryValue | null): number | null {
}
export async function updateUserCcRc(formData: FormData) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const actor = await requirePageWrite("players");
const userId = Number(formData.get("userId"));
const tabRaw = String(formData.get("tab") ?? "");
@@ -35,6 +32,8 @@ export async function updateUserCcRc(formData: FormData) {
? "matchmaker"
: tabRaw === "ledger"
? "ledger"
: tabRaw === "analysis"
? "analysis"
: "dashboard";
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
@@ -125,5 +124,13 @@ export async function updateUserCcRc(formData: FormData) {
);
}
await appendAuditLog({
username: actor.username,
accountId: actor.id,
action: "players.update_cc_rc",
summary: `Updated CC/RC for player #${userId}`,
details: { userId, cc, rc },
});
redirect(buildDashboardHref(base));
}
+69 -4
View File
@@ -1,4 +1,18 @@
import { NextResponse } from "next/server";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
getRequestUserAgent,
parseDeviceFromUserAgent,
} from "@/lib/auth/client-device";
import { getClientIp } from "@/lib/auth/client-ip";
import { verifyCredentials } from "@/lib/auth/credentials";
import { createSessionToken } from "@/lib/auth/roles";
import {
checkLoginRateLimit,
clearLoginAttempts,
rateLimitedLoginResponse,
recordFailedLogin,
} from "@/lib/auth/login-rate-limit";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import {
applySessionCookie,
@@ -10,8 +24,26 @@ function invalidRedirect(request: Request) {
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
}
function invalidJson() {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
export async function POST(request: Request) {
const contentType = request.headers.get("content-type") ?? "";
const clientIp = getClientIp(request);
const { device, userAgent } = parseDeviceFromUserAgent(
getRequestUserAgent(request),
);
const rateLimit = checkLoginRateLimit(clientIp);
if (!rateLimit.allowed) {
return rateLimitedLoginResponse(
request,
contentType,
rateLimit.retryAfterSeconds,
publicRequestUrl,
);
}
let username: string;
let password: string;
@@ -31,24 +63,57 @@ export async function POST(request: Request) {
password = String(formData.get("password") ?? "");
}
if (username !== "admin" || password !== "admin") {
let account;
try {
account = await verifyCredentials(username, password);
} catch {
recordFailedLogin(clientIp);
if (contentType.includes("application/json")) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
return NextResponse.json(
{ error: "Admin account is not configured" },
{ status: 503 },
);
}
return NextResponse.redirect(
publicRequestUrl(request, "/login?error=1"),
);
}
if (!account) {
recordFailedLogin(clientIp);
if (contentType.includes("application/json")) {
return invalidJson();
}
return invalidRedirect(request);
}
clearLoginAttempts(clientIp);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "auth.login",
summary: `Signed in from ${clientIp} · ${device}`,
ip: clientIp,
device,
userAgent,
details: {
ip: clientIp,
device,
userAgent,
},
});
if (contentType.includes("application/json")) {
const res = NextResponse.json({ ok: true });
res.cookies.set(
ADMIN_SESSION_COOKIE,
"1",
createSessionToken(account.username),
getSessionCookieSetOptions(request),
);
return res;
}
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
applySessionCookie(res, request);
applySessionCookie(res, request, account.username);
return res;
}
+25
View File
@@ -1,8 +1,33 @@
import { NextResponse } from "next/server";
import { appendAuditLog } from "@/lib/auth/audit-log";
import {
getRequestUserAgent,
parseDeviceFromUserAgent,
} from "@/lib/auth/client-device";
import { getClientIp } from "@/lib/auth/client-ip";
import { getSessionAccount } from "@/lib/auth/require-session";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
export async function POST(request: Request) {
const account = await getSessionAccount();
if (account) {
const clientIp = getClientIp(request);
const { device, userAgent } = parseDeviceFromUserAgent(
getRequestUserAgent(request),
);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "auth.logout",
summary: `Signed out from ${clientIp} · ${device}`,
ip: clientIp,
device,
userAgent,
details: { ip: clientIp, device, userAgent },
});
}
const res = NextResponse.json({ ok: true });
res.cookies.set(
ADMIN_SESSION_COOKIE,
+7 -4
View File
@@ -1,5 +1,5 @@
import { cookies } from "next/headers";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import {
parseMatchIdParam,
readMatchLogFile,
@@ -9,8 +9,11 @@ export async function GET(
_request: Request,
context: { params: Promise<{ matchId: string }> },
) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
const account = await getSessionAccount();
if (
!account ||
(!canReadPage(account, "matches") && !canReadPage(account, "analysis"))
) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
+4 -4
View File
@@ -1,13 +1,13 @@
import { cookies } from "next/headers";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { getSessionAccount } from "@/lib/auth/require-session";
import { canReadPage } from "@/lib/auth/permissions";
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
export async function GET(request: Request) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
const account = await getSessionAccount();
if (!account || !canReadPage(account, "matchmaker")) {
return Response.json(
{ error: "Unauthorized" },
{ status: 401, headers: NO_STORE },
+5 -7
View File
@@ -1,10 +1,10 @@
import { Fragment } from "react";
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AdminHeader } from "@/components/admin-header";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { requirePageRead } from "@/lib/auth/require-session";
import {
formatRcDecimalFromCoinsBigInt,
formatRcLabelFromCoinsBigInt,
@@ -58,10 +58,8 @@ export default async function LedgerBookPage({
}: {
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const account = await requirePageRead("ledger");
await logPageAccess(account, "ledger-book");
const sp = await searchParams;
let fromStr = firstSearchParam(sp.from);
@@ -151,7 +149,7 @@ export default async function LedgerBookPage({
return (
<div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950">
<AdminHeader />
<AdminHeader username={account.username} isAdmin={account.isAdmin} />
<main className="flex-1 space-y-6 px-6 py-8">
<div className="mx-auto max-w-[1200px] space-y-2">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
+6 -10
View File
@@ -5,6 +5,7 @@ type Props = {
export default async function LoginPage({ searchParams }: Props) {
const { error } = await searchParams;
const invalid = error === "1";
const locked = error === "locked";
return (
<div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950">
@@ -12,16 +13,6 @@ export default async function LoginPage({ searchParams }: Props) {
<h1 className="text-center text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Admin sign in
</h1>
<p className="mt-2 text-center text-sm text-zinc-500 dark:text-zinc-400">
Default:{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
admin
</code>{" "}
/{" "}
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
admin
</code>
</p>
<form
action="/api/auth/login"
method="post"
@@ -58,6 +49,11 @@ export default async function LoginPage({ searchParams }: Props) {
required
/>
</div>
{locked ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
Too many failed attempts. Wait about 15 minutes, then try again.
</p>
) : null}
{invalid ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
Invalid username or password.
+13 -6
View File
@@ -1,13 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { notFound } from "next/navigation";
import { canReadPage } from "@/lib/auth/permissions";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { requireSession } from "@/lib/auth/require-session";
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
import {
parseMatchIdParam,
readMatchLogFile,
} from "@/lib/match-logs-server";
import { redirect } from "next/navigation";
export async function generateMetadata({
params,
@@ -25,9 +27,12 @@ export default async function MatchLogPage({
}: {
params: Promise<{ matchId: string }>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
const account = await requireSession();
if (
!canReadPage(account, "matches") &&
!canReadPage(account, "analysis")
) {
redirect("/");
}
const { matchId: raw } = await params;
@@ -36,6 +41,8 @@ export default async function MatchLogPage({
notFound();
}
await logPageAccess(account, "match-log", { matchId });
const result = await readMatchLogFile(matchId);
return (
+4 -7
View File
@@ -1,8 +1,7 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { requirePageRead } from "@/lib/auth/require-session";
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
@@ -17,10 +16,8 @@ export default async function MatchmakerLogsPage({
}: {
searchParams: Promise<{ mklog?: string | string[] }>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const account = await requirePageRead("matchmaker");
await logPageAccess(account, "matchmaker-logs");
const sp = await searchParams;
const raw =
+199 -12
View File
@@ -1,11 +1,23 @@
import { AdminDashboard } from "@/components/admin-dashboard";
import { AdminHeader } from "@/components/admin-header";
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
import {
canReadPage,
canWritePage,
tabToPageKey,
} from "@/lib/auth/permissions";
import { requireSession } from "@/lib/auth/require-session";
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
import {
normalizeLedgerDateRange,
parseIsoDateOnly,
utcLast30DaysDateRange,
} from "@/lib/ledger-utc-date-range";
import {
localDateRangeToUtcIsoBounds,
normalizeMatchesDateRange,
parseTzOffsetMinutes,
} from "@/lib/local-date-range";
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
import {
serializeLedgerGlobalSummary,
@@ -18,7 +30,21 @@ import {
type MatchmakerLogSource,
} from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
import { fetchEntryHoldPerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
import { fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger";
import {
analyzeMatchLogsForMatches,
parseAnalysisPlayerIds,
} from "@/lib/match-log-analysis-server";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
loadGeolocationAnalytics,
type GeolocationAnalyticsResult,
} from "@/lib/geolocation-analytics";
import {
loadPingAnalytics,
type PingAnalyticsResult,
} from "@/lib/ping-analytics";
import type {
AdminMatchRow,
DbMatch,
@@ -35,6 +61,9 @@ import {
type LedgerSortKey,
type LedgerSortOrder,
} from "@/lib/ledger-table-view";
import { redirect } from "next/navigation";
import { readAuditLog, type AuditLogEntry } from "@/lib/auth/audit-log";
import { logPageAccess } from "@/lib/auth/log-page-access";
export const dynamic = "force-dynamic";
@@ -55,6 +84,7 @@ export default async function Home({
participant?: string | string[];
edit?: string | string[];
saveError?: string | string[];
supplyErr?: string | string[];
mklog?: string | string[];
lfrom?: string | string[];
lto?: string | string[];
@@ -62,11 +92,29 @@ export default async function Home({
lsize?: string | string[];
lsort?: string | string[];
lorder?: string | string[];
afrom?: string | string[];
ato?: string | string[];
aplayers?: string | string[];
mfrom?: string | string[];
mto?: string | string[];
mtz?: string | string[];
}>;
}) {
const account = await requireSession();
const canWritePlayers = canWritePage(account, "players");
const canWriteLedger = canWritePage(account, "ledger");
const pageAccess = {
players: canReadPage(account, "players"),
matches: canReadPage(account, "matches"),
analysis: canReadPage(account, "analysis"),
matchmaker: canReadPage(account, "matchmaker"),
ledger: canReadPage(account, "ledger"),
logs: canReadPage(account, "logs"),
};
const sp = await searchParams;
const tabParam = firstSearchParam(sp.tab);
const tab: AdminDashboardTab =
const requestedTab: AdminDashboardTab =
tabParam === "matches"
? "matches"
: tabParam === "players"
@@ -75,11 +123,27 @@ export default async function Home({
? "matchmaker"
: tabParam === "ledger"
? "ledger"
: tabParam === "analysis"
? "analysis"
: tabParam === "logs"
? "logs"
: "dashboard";
const pageKey = tabToPageKey(requestedTab);
if (pageKey && !canReadPage(account, pageKey)) {
redirect("/");
}
const tab = requestedTab;
const accessPage =
tab === "dashboard"
? "overview"
: tab;
await logPageAccess(account, accessPage);
const highlightId = firstSearchParam(sp.highlight);
const participantRaw = firstSearchParam(sp.participant);
const editRaw = firstSearchParam(sp.edit);
const saveError = firstSearchParam(sp.saveError) === "1";
const supplyError = firstSearchParam(sp.supplyErr) === "1";
const editIdNum =
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
@@ -101,12 +165,43 @@ export default async function Home({
let ledgerSortOrderView: LedgerSortOrder = "desc";
let ledgerFrom = utcLast30DaysDateRange().from;
let ledgerTo = utcLast30DaysDateRange().to;
let analysisFrom = utcLast30DaysDateRange().from;
let analysisTo = utcLast30DaysDateRange().to;
let matchesFrom = utcLast30DaysDateRange().from;
let matchesTo = utcLast30DaysDateRange().to;
let matchesTzOffsetMinutes: number | null = null;
let matchesRangeExplicit = false;
let analysisPlayerIds: number[] = [];
let matchAnalysis: MatchLogAnalysisResult = emptyMatchLogAnalysisResult();
let pingAnalytics: PingAnalyticsResult = {
summary: {
totalReports: 0,
totalMatches: 0,
totalPlayers: 0,
avgPing: null,
p95Ping: null,
badThresholdMs: 200,
badReports: 0,
badRatePercent: null,
},
byMatches: [],
byPlayers: [],
byCountries: [],
error: null,
};
let geolocationAnalytics: GeolocationAnalyticsResult = {
userAccounts: [],
matches: [],
error: null,
};
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
null;
let matchmakerSource: MatchmakerLogSource = "processed";
let matchmakerContent = "";
let matchmakerError: string | null = null;
let auditEntries: AuditLogEntry[] = [];
let auditError: string | null = null;
if (tab === "matchmaker") {
const mkRaw = firstSearchParam(sp.mklog);
matchmakerSource = parseMatchmakerLogParam(mkRaw);
@@ -118,13 +213,22 @@ export default async function Home({
}
}
if (tab === "logs") {
try {
auditEntries = await readAuditLog(1000);
} catch (err) {
auditError =
err instanceof Error ? err.message : "Failed to read system logs.";
}
}
if (!supabase) {
configError =
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
} else {
const usersRes = await supabase
.from("users")
.select("id, created_at, username, cc, rc, last_logged_at")
.select("id, created_at, username, email, ip_address, cc, rc, last_logged_at")
.order("id", { ascending: false })
.limit(500);
@@ -134,17 +238,52 @@ export default async function Home({
users = (usersRes.data ?? []) as DbUser[];
}
const matchesRes = await supabase
const matchesTzRaw = firstSearchParam(sp.mtz);
matchesTzOffsetMinutes = parseTzOffsetMinutes(matchesTzRaw);
const mfromRaw = firstSearchParam(sp.mfrom);
const mtoRaw = firstSearchParam(sp.mto);
matchesRangeExplicit = Boolean(
mfromRaw &&
mtoRaw &&
parseIsoDateOnly(mfromRaw.trim()) &&
parseIsoDateOnly(mtoRaw.trim()),
);
const matchesRange = normalizeMatchesDateRange(
mfromRaw,
mtoRaw,
matchesTzOffsetMinutes,
);
matchesFrom = matchesRange.from;
matchesTo = matchesRange.to;
let matchesQuery = supabase
.from("matches")
.select("*")
.order("id", { ascending: false })
.limit(500);
.order("id", { ascending: false });
if (tab === "matches") {
const tz = matchesTzOffsetMinutes ?? 0;
const { rangeStartIso, rangeEndIso } = localDateRangeToUtcIsoBounds(
matchesFrom,
matchesTo,
tz,
);
matchesQuery = matchesQuery
.gte("created_at", rangeStartIso)
.lte("created_at", rangeEndIso)
.limit(10000);
} else {
matchesQuery = matchesQuery.limit(500);
}
const matchesRes = await matchesQuery;
if (matchesRes.error) {
matchesError = matchesRes.error.message;
} else {
const rawMatches = (matchesRes.data ?? []) as DbMatch[];
const holdByMatch = await fetchEntryHoldPerPlayerCoinsByMatchIds(
const { holdPerPlayer, feePerPlayer } =
await fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
supabase,
rawMatches
.map((m) => Number(m.id))
@@ -152,13 +291,16 @@ export default async function Home({
);
matches = rawMatches.map((m) => {
const idNum = Number(m.id);
const hold = Number.isFinite(idNum)
? holdByMatch.get(idNum)
: undefined;
const hold =
Number.isFinite(idNum) ? holdPerPlayer.get(idNum) : undefined;
const fee =
Number.isFinite(idNum) ? feePerPlayer.get(idNum) : undefined;
return {
...m,
entryHoldPerPlayerCoins:
hold !== undefined ? hold.toString() : null,
entryFeePerPlayerCoins:
fee !== undefined ? fee.toString() : null,
};
});
}
@@ -223,16 +365,44 @@ export default async function Home({
ledgerTotalPagesView = slice.totalPages;
}
}
if (tab === "analysis") {
const afromRaw = firstSearchParam(sp.afrom);
const atoRaw = firstSearchParam(sp.ato);
const range = normalizeLedgerDateRange(afromRaw, atoRaw);
analysisFrom = range.from;
analysisTo = range.to;
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
const [matchLogResult, pingResult, geoResult] = await Promise.all([
analyzeMatchLogsForMatches(
matches,
analysisFrom,
analysisTo,
analysisPlayerIds,
),
loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds),
loadGeolocationAnalytics(
supabase,
users,
analysisFrom,
analysisTo,
analysisPlayerIds,
),
]);
matchAnalysis = matchLogResult;
pingAnalytics = pingResult;
geolocationAnalytics = geoResult;
}
}
let editUser: DbUser | null = null;
if (Number.isInteger(editIdNum) && editIdNum >= 1) {
if (canWritePlayers && Number.isInteger(editIdNum) && editIdNum >= 1) {
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
}
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader />
<AdminHeader username={account.username} isAdmin={account.isAdmin} />
{configError ? (
<div className="px-6 pt-8">
<div
@@ -268,6 +438,23 @@ export default async function Home({
ledgerOrder={ledgerSortOrderView}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
supplyError={supplyError}
matchesFrom={matchesFrom}
matchesTo={matchesTo}
matchesTzOffsetMinutes={matchesTzOffsetMinutes}
matchesRangeExplicit={matchesRangeExplicit}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
analysisPlayerIds={analysisPlayerIds}
matchAnalysis={matchAnalysis}
pingAnalytics={pingAnalytics}
geolocationAnalytics={geolocationAnalytics}
auditEntries={auditEntries}
auditError={auditError}
pageAccess={pageAccess}
canWritePlayers={canWritePlayers}
canWriteLedger={canWriteLedger}
isAdmin={account.isAdmin}
/>
{editUser ? (
<EditUserCcRcOverlay
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ReplayViewer } from "@/components/replay-viewer";
import { canReadPage } from "@/lib/auth/permissions";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { requireSession } from "@/lib/auth/require-session";
export const metadata: Metadata = {
title: "Replay viewer · Kick Kings Admin",
};
export default async function ReplaysPage() {
const account = await requireSession();
if (
!canReadPage(account, "matches") &&
!canReadPage(account, "analysis")
) {
redirect("/");
}
await logPageAccess(account, "replay-viewer");
return (
<div className="flex min-h-dvh flex-col bg-[#0d1117] text-zinc-100">
<header className="flex shrink-0 items-center gap-2 border-b border-zinc-700/80 bg-[#161b22] px-3 py-2">
<div className="flex gap-1.5 pr-2" aria-hidden="true">
<span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
<span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
<span className="h-3 w-3 rounded-full bg-[#27c93f]" />
</div>
<p className="min-w-0 flex-1 truncate font-mono text-xs text-zinc-300">
<span className="text-emerald-400/90">soccar</span>
<span className="text-zinc-500"> </span>
<span className="text-zinc-100">replay viewer</span>
</p>
<Link
href="/?tab=matches"
className="shrink-0 rounded border border-zinc-600 bg-zinc-800 px-2.5 py-1 font-mono text-xs text-zinc-200 transition hover:bg-zinc-700 hover:text-white"
>
Back to matches
</Link>
</header>
<div className="flex min-h-0 flex-1 flex-col p-4">
<ReplayViewer />
</div>
</div>
);
}
+32 -11
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth/require-session";
import { listSessionAccounts } from "@/lib/auth/accounts-store";
import { logPageAccess } from "@/lib/auth/log-page-access";
import { AdminHeader } from "@/components/admin-header";
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { AdminAccountsEditor } from "@/components/admin-accounts-editor";
import { createAdminSupabase } from "@/lib/supabase/admin";
import type { DbSetting } from "@/types/database";
@@ -29,16 +30,20 @@ export default async function SettingsPage({
searchParams: Promise<{
saveError?: string | string[];
addError?: string | string[];
accountError?: string | string[];
accountOk?: string | string[];
}>;
}) {
const cookieStore = await cookies();
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
redirect("/login");
}
const account = await requireAdmin();
await logPageAccess(account, "settings");
const sp = await searchParams;
const saveError = firstSearchParam(sp.saveError) === "1";
const addError = firstSearchParam(sp.addError);
const accountError = firstSearchParam(sp.accountError);
const accountOk = firstSearchParam(sp.accountOk);
const accounts = await listSessionAccounts();
const supabase = createAdminSupabase();
let rows: DbSetting[] = [];
@@ -63,12 +68,27 @@ export default async function SettingsPage({
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader />
<main className="flex-1 px-6 py-8">
<div className="mx-auto mb-8 max-w-[900px]">
<AdminHeader username={account.username} isAdmin={account.isAdmin} />
<main className="flex-1 space-y-12 px-6 py-8">
<div className="mx-auto max-w-[900px]">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Global settings
Settings
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Global game settings and panel account access. Admin only.
</p>
</div>
<AdminAccountsEditor
accounts={accounts}
accountError={accountError}
accountOk={accountOk}
/>
<div className="mx-auto max-w-[900px] border-t border-zinc-200 pt-10 dark:border-zinc-800">
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Global settings
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Key/value rows in the{" "}
<span className="font-mono text-zinc-800 dark:text-zinc-200">
@@ -96,6 +116,7 @@ export default async function SettingsPage({
rows={rows}
saveError={saveError}
addError={addError}
readOnly={false}
/>
)}
</main>
+434
View File
@@ -0,0 +1,434 @@
"use client";
import { useState } from "react";
import {
createAdminAccount,
deleteAdminAccount,
updateAdminAccount,
} from "@/app/actions/account-actions";
import {
PAGE_KEYS,
type PageKey,
type PagePermissions,
type SessionAccount,
} from "@/lib/auth/permissions";
const PAGE_LABELS: Record<PageKey, string> = {
players: "Players",
matches: "Matches",
analysis: "Analysis",
matchmaker: "Matchmaker",
ledger: "Ledger",
logs: "System logs",
};
type Props = {
accounts: SessionAccount[];
accountError: string | null;
accountOk: string | null;
};
function errorMessage(code: string | null): string | null {
if (!code) return null;
switch (code) {
case "missingUsername":
return "Username is required.";
case "missingPassword":
return "Password is required.";
case "duplicate":
return "That username is already taken.";
case "notFound":
return "Account not found.";
case "cannotDeleteAdmin":
return "The admin account cannot be deleted.";
default:
return "Could not update accounts. Try again.";
}
}
function okMessage(code: string | null): string | null {
if (!code) return null;
switch (code) {
case "created":
return "Account created.";
case "updated":
return "Account updated.";
case "deleted":
return "Account deleted.";
default:
return "Done.";
}
}
function PermissionCheckboxes({
idPrefix,
permissions,
onChange,
}: {
idPrefix: string;
permissions: PagePermissions;
onChange: (next: PagePermissions) => void;
}) {
function setPerm(page: PageKey, field: "read" | "write", value: boolean) {
const next = { ...permissions, [page]: { ...permissions[page] } };
if (page === "logs") {
next[page] = { read: field === "read" ? value : next[page].read, write: false };
onChange(next);
return;
}
if (field === "write") {
next[page] = { write: value, read: value ? true : next[page].read };
} else {
next[page] = {
read: value,
write: value ? next[page].write : false,
};
}
onChange(next);
}
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[320px] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
<th className="py-2 pr-3 font-medium">Page</th>
<th className="py-2 px-2 font-medium">Read</th>
<th className="py-2 px-2 font-medium">Write</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-zinc-100 dark:border-zinc-800">
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
Overview
</td>
<td className="py-2 px-2 text-zinc-500" colSpan={2}>
Always enabled
</td>
</tr>
{PAGE_KEYS.map((page) => (
<tr
key={page}
className="border-b border-zinc-100 dark:border-zinc-800"
>
<td className="py-2 pr-3 text-zinc-800 dark:text-zinc-200">
{PAGE_LABELS[page]}
</td>
<td className="py-2 px-2">
<input
type="checkbox"
id={`${idPrefix}_${page}_read`}
name={`perm_${page}_read`}
value="1"
checked={permissions[page].read}
onChange={(e) => setPerm(page, "read", e.target.checked)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
/>
</td>
<td className="py-2 px-2">
{page === "logs" ? (
<span className="text-xs text-zinc-500 dark:text-zinc-400">
</span>
) : (
<input
type="checkbox"
id={`${idPrefix}_${page}_write`}
name={`perm_${page}_write`}
value="1"
checked={permissions[page].write}
onChange={(e) => setPerm(page, "write", e.target.checked)}
className="size-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-400 dark:border-zinc-600"
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function permissionSummary(account: SessionAccount): string {
if (account.isAdmin) return "Full access";
const parts: string[] = ["Overview"];
for (const page of PAGE_KEYS) {
const p = account.permissions[page];
if (p.write) parts.push(`${PAGE_LABELS[page]} (rw)`);
else if (p.read) parts.push(`${PAGE_LABELS[page]} (r)`);
}
return parts.join(", ");
}
function AccountEditForm({
account,
onClose,
}: {
account: SessionAccount;
onClose: () => void;
}) {
const [username, setUsername] = useState(account.username);
const [password, setPassword] = useState("");
const [permissions, setPermissions] = useState(account.permissions);
return (
<form
action={updateAdminAccount}
className="mt-3 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-950/60"
>
<input type="hidden" name="id" value={account.id} />
<input
type="hidden"
name="isAdmin"
value={account.isAdmin ? "1" : "0"}
/>
{account.isAdmin ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Admin account username is fixed; only the password can be changed.
</p>
) : (
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`edit-username-${account.id}`}
>
Username
</label>
<input
id={`edit-username-${account.id}`}
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
/>
</div>
)}
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`edit-password-${account.id}`}
>
{account.isAdmin ? "New password" : "New password (optional)"}
</label>
<input
id={`edit-password-${account.id}`}
name="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={account.isAdmin}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
/>
</div>
{!account.isAdmin ? (
<PermissionCheckboxes
idPrefix={`edit_${account.id}`}
permissions={permissions}
onChange={setPermissions}
/>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="submit"
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
</div>
</form>
);
}
function AddAccountForm() {
const [permissions, setPermissions] = useState<PagePermissions>({
players: { read: false, write: false },
matches: { read: false, write: false },
analysis: { read: false, write: false },
matchmaker: { read: false, write: false },
ledger: { read: false, write: false },
logs: { read: false, write: false },
});
return (
<form
action={createAdminAccount}
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Add account
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor="new-account-username"
>
Username
</label>
<input
id="new-account-username"
name="username"
required
autoComplete="off"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor="new-account-password"
>
Password
</label>
<input
id="new-account-password"
name="password"
type="password"
required
autoComplete="new-password"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
</div>
<PermissionCheckboxes
idPrefix="new"
permissions={permissions}
onChange={setPermissions}
/>
<button
type="submit"
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Create account
</button>
</form>
);
}
export function AdminAccountsEditor({
accounts,
accountError,
accountOk,
}: Props) {
const [editingId, setEditingId] = useState<string | null>(null);
const err = errorMessage(accountError);
const ok = okMessage(accountOk);
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<div>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Accounts
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Manage panel logins and per-page read/write access. Only the admin
account can manage other accounts.
</p>
</div>
{err ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{err}
</div>
) : null}
{ok ? (
<div
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100"
role="status"
>
{ok}
</div>
) : null}
<section className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Existing accounts
</h3>
<ul className="space-y-3">
{accounts.map((account) => (
<li
key={account.id}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-50">
{account.username}
{account.isAdmin ? (
<span className="ml-2 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
Admin
</span>
) : null}
</p>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
{permissionSummary(account)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() =>
setEditingId((id) =>
id === account.id ? null : account.id,
)
}
className="rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{editingId === account.id ? "Close" : "Edit"}
</button>
{!account.isAdmin ? (
<form action={deleteAdminAccount}>
<input type="hidden" name="id" value={account.id} />
<button
type="submit"
className="rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (
!window.confirm(
`Delete account “${account.username}”?`,
)
) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
) : null}
</div>
</div>
{editingId === account.id ? (
<AccountEditForm
account={account}
onClose={() => setEditingId(null)}
/>
) : null}
</li>
))}
</ul>
</section>
<AddAccountForm />
</div>
);
}
+310 -14
View File
@@ -4,14 +4,24 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { AdminLedger } from "@/components/admin-ledger";
import { AdminSystemLogs } from "@/components/admin-system-logs";
import { LocalTimestamp } from "@/components/local-timestamp";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { AuditLogEntry } from "@/lib/auth/audit-log";
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
buildDashboardHref,
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import {
localLast30DaysDateRange,
} from "@/lib/local-date-range";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import type {
AdminMatchRow,
@@ -66,11 +76,6 @@ function formatTs(value: string | null): string {
}
}
function matchEntryFeeCoinString(v: DbMatch["entry_fee"]): string | null {
if (v == null) return null;
return txAmountToBigInt(v).toString();
}
function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
if (v == null) return "—";
const b = txAmountToBigInt(v);
@@ -220,6 +225,33 @@ type Props = {
matchmakerSource: MatchmakerLogSource;
matchmakerContent: string;
matchmakerError: string | null;
/** Ledger: add-system-supply action failed (URL `supplyErr=1`). */
supplyError: boolean;
/** Matches tab: local date-only bounds + browser tz offset minutes. */
matchesFrom: string;
matchesTo: string;
matchesTzOffsetMinutes: number | null;
/** True when `mfrom`/`mto` were present and valid in the URL. */
matchesRangeExplicit: boolean;
analysisFrom: string;
analysisTo: string;
analysisPlayerIds: number[];
matchAnalysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
geolocationAnalytics: GeolocationAnalyticsResult;
auditEntries: AuditLogEntry[];
auditError: string | null;
pageAccess: {
players: boolean;
matches: boolean;
analysis: boolean;
matchmaker: boolean;
ledger: boolean;
logs: boolean;
};
canWritePlayers: boolean;
canWriteLedger: boolean;
isAdmin: boolean;
};
function StatCard({
@@ -269,11 +301,56 @@ export function AdminDashboard({
matchmakerSource,
matchmakerContent,
matchmakerError,
supplyError,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
matchesRangeExplicit,
analysisFrom,
analysisTo,
analysisPlayerIds,
matchAnalysis,
pingAnalytics,
geolocationAnalytics,
auditEntries,
auditError,
pageAccess,
canWritePlayers,
canWriteLedger,
isAdmin,
}: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true);
const [playersSearch, setPlayersSearch] = useState("");
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
/** Once we know the browser offset, reload with mtz so day bounds are local. */
useEffect(() => {
if (tab !== "matches") return;
if (matchesTzOffsetMinutes != null) return;
const tz = new Date().getTimezoneOffset();
const range = matchesRangeExplicit
? { from: matchesFrom, to: matchesTo }
: localLast30DaysDateRange(tz);
const href = buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesFrom: range.from,
matchesTo: range.to,
matchesTzOffsetMinutes: tz,
});
window.location.replace(href);
}, [
tab,
matchesTzOffsetMinutes,
matchesRangeExplicit,
highlightId,
participantRaw,
matchesFrom,
matchesTo,
]);
const lbRaw = statsBundle?.leaderboard;
const sortedLeaderboard = useMemo(() => {
const rows = lbRaw ?? [];
@@ -322,6 +399,25 @@ export function AdminDashboard({
if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]);
const filteredUsers = useMemo(() => {
const q = playersSearch.trim().toLowerCase();
if (!q) return users;
return users.filter((u) => {
const haystack = [
String(u.id),
u.username ?? "",
u.email ?? "",
u.cc == null ? "" : String(u.cc),
u.rc == null ? "" : String(u.rc),
formatTs(u.created_at),
formatTs(u.last_logged_at),
u.ip_address ?? "",
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
});
}, [users, playersSearch]);
useEffect(() => {
if (tab !== "players" || !highlightId) return;
@@ -371,6 +467,7 @@ export function AdminDashboard({
>
Overview
</Link>
{pageAccess.players ? (
<Link
href={buildDashboardHref({
tab: "players",
@@ -387,11 +484,16 @@ export function AdminDashboard({
: null}
)
</Link>
) : null}
{pageAccess.matches ? (
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
})}
className={tabClass(tab === "matches")}
scroll={false}
@@ -403,6 +505,27 @@ export function AdminDashboard({
: null}
)
</Link>
) : null}
{pageAccess.analysis ? (
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
analysisFrom,
analysisTo,
analysisPlayers:
analysisPlayerIds.length > 0
? analysisPlayerIds.join(",")
: null,
})}
className={tabClass(tab === "analysis")}
scroll={false}
>
Analysis
</Link>
) : null}
{pageAccess.matchmaker ? (
<Link
href={buildDashboardHref({
tab: "matchmaker",
@@ -414,6 +537,8 @@ export function AdminDashboard({
>
Matchmaker
</Link>
) : null}
{pageAccess.ledger ? (
<Link
href={buildDashboardHref({
tab: "ledger",
@@ -425,6 +550,30 @@ export function AdminDashboard({
>
Ledger
</Link>
) : null}
{pageAccess.logs ? (
<Link
href={buildDashboardHref({
tab: "logs",
highlightId,
participantRaw,
})}
className={tabClass(tab === "logs")}
scroll={false}
>
System logs
</Link>
) : null}
{pageAccess.matches || pageAccess.analysis ? (
<Link
href="/replays"
className={tabClass(false)}
scroll={false}
>
Replays
</Link>
) : null}
{isAdmin ? (
<Link
href="/settings"
className={tabClass(false)}
@@ -432,6 +581,7 @@ export function AdminDashboard({
>
Settings
</Link>
) : null}
</div>
<div className="mx-auto w-full max-w-[1400px]">
@@ -521,6 +671,7 @@ export function AdminDashboard({
activeDir={lbSortDir}
onActivate={onLbSort}
/>
<th className="px-4 py-3 font-medium">Email</th>
<LeaderboardSortTh
label="RC balance"
sortKey="rc"
@@ -550,7 +701,7 @@ export function AdminDashboard({
{(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr>
<td
colSpan={5}
colSpan={6}
className="px-4 py-8 text-center text-zinc-500"
>
No wins recorded yet (no rows with a winner).
@@ -578,6 +729,9 @@ export function AdminDashboard({
)}
</span>
</td>
<td className="max-w-48 truncate px-4 py-2 text-zinc-700 dark:text-zinc-300">
{row.email?.trim() ? row.email.trim() : "—"}
</td>
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
{formatRcBalanceWithCoins(row.rcBalance)}
</td>
@@ -605,31 +759,51 @@ export function AdminDashboard({
{usersError ? (
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<label className="min-w-[16rem] flex-1">
<span className="sr-only">Search players</span>
<input
type="search"
value={playersSearch}
onChange={(e) => setPlayersSearch(e.target.value)}
placeholder="Search by any field (id, username, email, CC, RC, timestamps)"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-500 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-400"
/>
</label>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filteredUsers.length} of {users.length}
</span>
</div>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Username</th>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">CC</th>
<th className="px-4 py-3 font-medium">RC</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{users.length === 0 ? (
{filteredUsers.length === 0 ? (
<tr>
<td
colSpan={7}
colSpan={9}
className="px-4 py-8 text-center text-zinc-500"
>
No players yet.
{users.length === 0
? "No players yet."
: "No players match this search."}
</td>
</tr>
) : (
users.map((u) => {
filteredUsers.map((u) => {
const isHi =
highlightId != null &&
highlightId === String(u.id);
@@ -647,6 +821,9 @@ export function AdminDashboard({
<ClickableUserId id={u.id} />
</td>
<td className="px-4 py-2">{u.username ?? "—"}</td>
<td className="max-w-48 truncate px-4 py-2">
{u.email?.trim() ? u.email.trim() : "—"}
</td>
<td className="px-4 py-2">{u.cc ?? "—"}</td>
<td className="px-4 py-2">{u.rc ?? "—"}</td>
<td className="px-4 py-2 whitespace-nowrap">
@@ -655,8 +832,12 @@ export function AdminDashboard({
<td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.last_logged_at)}
</td>
<td className="px-4 py-2 font-mono text-xs whitespace-nowrap">
{u.ip_address?.trim() ? u.ip_address.trim() : "—"}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2">
{canWritePlayers ? (
<Link
href={editHref(u)}
scroll={false}
@@ -664,6 +845,7 @@ export function AdminDashboard({
>
Edit
</Link>
) : null}
<Link
href={`/?tab=matches&participant=${u.id}`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
@@ -680,6 +862,7 @@ export function AdminDashboard({
</tbody>
</table>
</div>
</div>
)}
</section>
) : tab === "matchmaker" ? (
@@ -723,6 +906,18 @@ export function AdminDashboard({
errorMessage={matchmakerError}
/>
</section>
) : tab === "analysis" ? (
<AdminMatchAnalysis
analysis={matchAnalysis}
pingAnalytics={pingAnalytics}
geolocationAnalytics={geolocationAnalytics}
users={users}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
selectedPlayerIds={analysisPlayerIds}
highlightId={highlightId}
participantRaw={participantRaw}
/>
) : tab === "ledger" ? (
<AdminLedger
ledgerGlobalSummary={ledgerGlobalSummary}
@@ -740,9 +935,103 @@ export function AdminDashboard({
participantRaw={participantRaw}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
supplyError={supplyError}
readOnly={!canWriteLedger}
/>
) : tab === "logs" ? (
<AdminSystemLogs entries={auditEntries} error={auditError} />
) : (
<section className="space-y-3">
<form
method="get"
action="/"
className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
onSubmit={(e) => {
const form = e.currentTarget;
const mtzInput = form.elements.namedItem(
"mtz",
) as HTMLInputElement | null;
if (mtzInput) {
mtzInput.value = String(new Date().getTimezoneOffset());
}
}}
>
<input type="hidden" name="tab" value="matches" />
{highlightId ? (
<input type="hidden" name="highlight" value={highlightId} />
) : null}
{participantRaw ? (
<input type="hidden" name="participant" value={participantRaw} />
) : null}
<input
type="hidden"
name="mtz"
defaultValue={
matchesTzOffsetMinutes != null
? String(matchesTzOffsetMinutes)
: ""
}
/>
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="matches-mfrom"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
From (local)
</label>
<input
id="matches-mfrom"
name="mfrom"
type="date"
defaultValue={matchesFrom}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="matches-mto"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
To (local)
</label>
<input
id="matches-mto"
name="mto"
type="date"
defaultValue={matchesTo}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply
</button>
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
matchesTzOffsetMinutes,
})}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Reset range
</Link>
</form>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {matches.length.toLocaleString("en-US")}{" "}
{matches.length === 1 ? "match" : "matches"} created{" "}
<span className="font-mono">
{matchesFrom}{matchesTo}
</span>{" "}
in your local timezone
{matches.length >= 10000 ? " · capped at 10,000 rows" : null}
</p>
{participantId != null ? (
<div
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100"
@@ -764,7 +1053,14 @@ export function AdminDashboard({
{filteredMatches.length} of {matches.length})
</span>
<Link
href="/?tab=matches"
href={buildDashboardHref({
tab: "matches",
highlightId: null,
participantRaw: null,
matchesFrom,
matchesTo,
matchesTzOffsetMinutes,
})}
className="shrink-0 rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-medium text-sky-900 shadow-sm hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-900 dark:text-sky-50 dark:hover:bg-sky-800"
scroll={false}
>
@@ -800,7 +1096,7 @@ export function AdminDashboard({
{visibleMatches.length === 0 ? (
<div className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
{filteredMatches.length === 0
? "No matches yet."
? "No matches in this date range."
: hideNoWinner
? "No matches left after hiding no-winner matches."
: "No matches for this filter."}
@@ -818,8 +1114,8 @@ export function AdminDashboard({
key={m.id}
matchId={m.id}
statusLabel={statusLabel(m.status)}
createdAtLabel={formatTs(m.created_at)}
entryFeeCoins={matchEntryFeeCoinString(m.entry_fee)}
createdAtLabel={<LocalTimestamp value={m.created_at} />}
entryFeeCoins={m.entryFeePerPlayerCoins}
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
winnerId={m.winner_id}
+15 -1
View File
@@ -1,6 +1,11 @@
"use client";
export function AdminHeader() {
type Props = {
username?: string;
isAdmin?: boolean;
};
export function AdminHeader({ username, isAdmin = false }: Props) {
async function logout() {
await fetch("/api/auth/logout", { method: "POST" });
window.location.href = "/login";
@@ -12,6 +17,15 @@ export function AdminHeader() {
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Kick Kings Admin Dashboard
</h1>
{username ? (
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
Signed in as{" "}
<span className="font-medium text-zinc-700 dark:text-zinc-300">
{username}
</span>
{isAdmin ? " · admin" : null}
</p>
) : null}
</div>
<button
type="button"
+165 -2
View File
@@ -1,10 +1,13 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
import {
formatRcBalanceWithCoins,
formatRcLabelFromCoinsBigInt,
} from "@/lib/coins-rc";
import { auditMatchEconomics } from "@/lib/ledger-match-audit";
import {
computeLedgerIntegrity,
@@ -21,6 +24,7 @@ import {
type LedgerSortOrder,
} from "@/lib/ledger-table-view";
import { buildLedgerScopedDailyCumulativeSeries } from "@/lib/ledger-scoped-daily-series";
import { addSystemSupply } from "@/app/actions/add-system-supply";
import { LedgerRangeCumulativeChart } from "@/components/ledger-range-cumulative-chart";
import type { DbTransaction, DbUser } from "@/types/database";
@@ -86,6 +90,8 @@ type Props = {
participantRaw: string | null;
ledgerFrom: string;
ledgerTo: string;
supplyError: boolean;
readOnly: boolean;
};
export function AdminLedger({
@@ -104,6 +110,8 @@ export function AdminLedger({
participantRaw,
ledgerFrom,
ledgerTo,
supplyError,
readOnly,
}: Props) {
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
@@ -113,6 +121,13 @@ export function AdminLedger({
return m;
}, [users]);
const systemAccount = useMemo(() => {
for (const u of users) {
if (Number(u.id) === 1) return u;
}
return null;
}, [users]);
const { integrityGlobal, userNets } = useMemo(() => {
if (ledgerGlobalSummary == null) {
return {
@@ -240,6 +255,25 @@ export function AdminLedger({
);
const pageNavItems = ledgerPaginationItems(ledgerPage, ledgerTotalPages);
const [supplyModalOpen, setSupplyModalOpen] = useState(false);
useEffect(() => {
if (supplyError) {
setSupplyModalOpen(true);
}
}, [supplyError]);
useEffect(() => {
if (!supplyModalOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setSupplyModalOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [supplyModalOpen]);
const navLinkClass =
"rounded px-2 py-1 font-medium text-sky-700 underline decoration-sky-400/50 underline-offset-2 hover:bg-sky-50 dark:text-sky-300 dark:hover:bg-sky-950/40";
const navMutedClass = "rounded px-2 py-1 text-zinc-400 dark:text-zinc-500";
@@ -275,12 +309,25 @@ export function AdminLedger({
: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
].join(" ")}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
Σ user nets vs net issued
</p>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
All transactions
</p>
</div>
{!readOnly ? (
<button
type="button"
onClick={() => setSupplyModalOpen(true)}
className="shrink-0 rounded-md border border-zinc-300 bg-white/90 px-2.5 py-1 text-xs font-medium text-zinc-800 shadow-sm hover:bg-white dark:border-zinc-600 dark:bg-zinc-900/90 dark:text-zinc-100 dark:hover:bg-zinc-900"
>
Add supply
</button>
) : null}
</div>
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
<p className="font-mono">
{formatBigAmount(integrityGlobal.sumUserNets)}
@@ -956,6 +1003,122 @@ export function AdminLedger({
</div>
) : null}
{supplyModalOpen && !readOnly ? (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40 p-4"
role="presentation"
onClick={() => setSupplyModalOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="supply-modal-title"
className="w-full max-w-md rounded-xl border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
onClick={(e) => e.stopPropagation()}
>
<h2
id="supply-modal-title"
className="text-lg font-semibold text-zinc-900 dark:text-zinc-50"
>
Add system supply
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Credits user id{" "}
<span className="font-mono">1</span>
{systemAccount?.username ? (
<span>
{" "}
(<span className="font-mono">{systemAccount.username}</span>)
</span>
) : null}
: mint row (<span className="font-mono">to = 1</span>,{" "}
<span className="font-mono">remarks = supply</span>), RC increases
by the same coin amount.
</p>
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
Current RC:{" "}
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{systemAccount
? formatRcBalanceWithCoins(systemAccount.rc)
: "— (user 1 not in loaded set)"}
</span>
</p>
{supplyError ? (
<p
className="mt-3 text-sm text-red-600 dark:text-red-400"
role="alert"
>
Could not add supply. Use a positive whole number of coin units
(no decimals), and ensure the database and service key are
available.
</p>
) : null}
<form
action={addSystemSupply}
className="mt-5 space-y-4"
>
<input type="hidden" name="tab" value="ledger" />
<input
type="hidden"
name="highlightId"
value={highlightId ?? ""}
/>
<input
type="hidden"
name="participantRaw"
value={participantRaw ?? ""}
/>
<input type="hidden" name="ledgerFrom" value={ledgerFrom} />
<input type="hidden" name="ledgerTo" value={ledgerTo} />
<input
type="hidden"
name="ledgerPage"
value={String(ledgerPage)}
/>
<input
type="hidden"
name="ledgerPageSize"
value={String(ledgerPageSize)}
/>
<input type="hidden" name="ledgerSort" value={ledgerSort} />
<input type="hidden" name="ledgerOrder" value={ledgerOrder} />
<div>
<label
htmlFor="supply-amount-modal"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Coin amount
</label>
<input
id="supply-amount-modal"
name="supplyAmount"
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="e.g. 4000"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm tabular-nums text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setSupplyModalOpen(false)}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Cancel
</button>
<button
type="submit"
className="rounded-md border border-emerald-600 bg-emerald-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 dark:border-emerald-700 dark:bg-emerald-800 dark:hover:bg-emerald-700"
>
Submit
</button>
</div>
</form>
</div>
</div>
) : null}
</section>
);
}
+630
View File
@@ -0,0 +1,630 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
import { AnalysisGeolocationMap } from "@/components/analysis-geolocation-map";
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
import type { DbUser } from "@/types/database";
function formatDuration(seconds: number): string {
const s = Math.round(seconds);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
}
function formatAggregate(
agg: NumericAggregate | null,
formatValue: (n: number) => string,
): { avg: string; min: string; max: string } {
if (!agg) {
return { avg: "—", min: "—", max: "—" };
}
return {
avg: formatValue(agg.avg),
min: formatValue(agg.min),
max: formatValue(agg.max),
};
}
function StatBlock({
title,
avg,
min,
max,
hint,
}: {
title: string;
avg: string;
min: string;
max: string;
hint?: string;
}) {
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{title}
</p>
{hint ? (
<p className="mt-0.5 text-[11px] text-zinc-500 dark:text-zinc-400">
{hint}
</p>
) : null}
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
<div>
<dt className="text-[10px] uppercase text-zinc-500">Avg</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{avg}
</dd>
</div>
<div>
<dt className="text-[10px] uppercase text-zinc-500">Min</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{min}
</dd>
</div>
<div>
<dt className="text-[10px] uppercase text-zinc-500">Max</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold tabular-nums">
{max}
</dd>
</div>
</dl>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50/60 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950/40">
<p className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{label}
</p>
<p className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
{value}
</p>
</div>
);
}
type Props = {
analysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
geolocationAnalytics: GeolocationAnalyticsResult;
users: DbUser[];
analysisFrom: string;
analysisTo: string;
selectedPlayerIds: number[];
highlightId: string | null;
participantRaw: string | null;
};
export function AdminMatchAnalysis({
analysis,
pingAnalytics,
geolocationAnalytics,
users,
analysisFrom,
analysisTo,
selectedPlayerIds,
highlightId,
participantRaw,
}: Props) {
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
for (const u of users) m.set(u.id, u.username);
return m;
}, [users]);
const durationFmt = formatAggregate(analysis.duration, formatDuration);
const shotsFmt = formatAggregate(analysis.shots, (n) =>
Math.round(n).toLocaleString("en-US"),
);
const forceFmt = formatAggregate(analysis.force, (n) =>
n.toLocaleString("en-US", { maximumFractionDigits: 1 }),
);
const winnerRatioPct =
analysis.matchesWithLogFiles > 0
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
: null;
const fmtMs = (value: number | null): string =>
value == null ? "—" : `${Math.round(value).toLocaleString("en-US")} ms`;
const fmtPct = (value: number | null): string =>
value == null
? "—"
: `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })}%`;
const reports = pingAnalytics.summary.totalReports;
const avgPing = pingAnalytics.summary.avgPing;
const p95Ping = pingAnalytics.summary.p95Ping;
const badThreshold = pingAnalytics.summary.badThresholdMs;
const badReports = pingAnalytics.summary.badReports;
const badRate = pingAnalytics.summary.badRatePercent;
const goodCount =
reports > 0 && avgPing != null
? Math.max(0, reports - badReports)
: Math.max(0, reports - badReports);
const topCountryRows = pingAnalytics.byCountries.slice(0, 8);
const [pingView, setPingView] = useState<"matches" | "players" | "countries">(
"matches",
);
const pingRows =
pingView === "matches"
? pingAnalytics.byMatches
: pingView === "players"
? pingAnalytics.byPlayers
: pingAnalytics.byCountries;
return (
<section className="space-y-6">
{analysis.configError ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
{analysis.configError}
</div>
) : null}
<form
method="get"
action="/"
className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="tab" value="analysis" />
{highlightId ? (
<input type="hidden" name="highlight" value={highlightId} />
) : null}
{participantRaw ? (
<input type="hidden" name="participant" value={participantRaw} />
) : null}
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="analysis-afrom"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
From (UTC)
</label>
<input
id="analysis-afrom"
name="afrom"
type="date"
defaultValue={analysisFrom}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<div className="flex min-w-[10rem] flex-col gap-1">
<label
htmlFor="analysis-ato"
className="text-xs font-medium text-zinc-500 dark:text-zinc-400"
>
To (UTC)
</label>
<input
id="analysis-ato"
name="ato"
type="date"
defaultValue={analysisTo}
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
</div>
<fieldset>
<legend className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
Players (optional leave empty for all)
</legend>
<div className="mt-2 max-h-48 overflow-y-auto rounded-md border border-zinc-200 p-2 dark:border-zinc-700">
<div className="flex flex-wrap gap-x-4 gap-y-2">
{users.length === 0 ? (
<p className="text-sm text-zinc-500">No players loaded.</p>
) : (
users.map((u) => {
const checked = selectedPlayerIds.includes(u.id);
return (
<label
key={u.id}
className="flex cursor-pointer items-center gap-2 text-sm"
>
<input
type="checkbox"
name="aplayers"
value={String(u.id)}
defaultChecked={checked}
className="rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600"
/>
<span className="font-mono text-xs">
<ClickableUserId id={u.id} />
{u.username ? (
<span className="text-zinc-600 dark:text-zinc-400">
{" "}
({u.username})
</span>
) : null}
</span>
</label>
);
})
)}
</div>
</div>
</fieldset>
<div className="flex flex-wrap gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Apply
</button>
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
})}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 shadow-sm hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Reset filters
</Link>
</div>
</form>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-300">
<span className="font-mono">
{analysisFrom}{analysisTo}
</span>{" "}
UTC
{selectedPlayerIds.length > 0 ? (
<>
{" "}
· players{" "}
{selectedPlayerIds.map((id) => (
<span key={id} className="font-mono">
{id}
{usernameById.get(id) ? ` (${usernameById.get(id)})` : ""}
{" "}
</span>
))}
</>
) : (
" · all players"
)}
<br />
<span className="text-zinc-600 dark:text-zinc-400">
{analysis.matchesInFilter.toLocaleString("en-US")} matches in filter
{analysis.matchesScanned < analysis.matchesInFilter
? ` · scanned ${analysis.matchesScanned.toLocaleString("en-US")} (cap)`
: null}
· {analysis.matchesWithLogs.toLocaleString("en-US")} with parsed logs
{analysis.matchesMissingLogs > 0
? ` · ${analysis.matchesMissingLogs.toLocaleString("en-US")} missing or empty`
: null}
{analysis.matchesSkippedTooLarge > 0
? ` · ${analysis.matchesSkippedTooLarge.toLocaleString("en-US")} too large`
: null}
</span>
</div>
<AnalysisMatchAlertBox
category="mirror"
matchIds={analysis.mirrorExceptionDisconnectMatchIds}
title="Mirror exception disconnect"
description={
<>
These matches include a{" "}
<span className="font-mono">[Mirror/Error]</span> line where a
player was disconnected because handling a command caused an
exception.
</>
}
/>
<div className="grid gap-4 lg:grid-cols-3">
<StatBlock
title="Match length"
avg={durationFmt.avg}
min={durationFmt.min}
max={durationFmt.max}
hint="Ended matches only · first to last log line (m:ss)"
/>
<StatBlock
title="Shots per match"
avg={shotsFmt.avg}
min={shotsFmt.min}
max={shotsFmt.max}
hint="Ended matches only · launching puck lines"
/>
<StatBlock
title="Shot force magnitude"
avg={forceFmt.avg}
min={forceFmt.min}
max={forceFmt.max}
hint="√(fx² + fy²) per shot"
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Force distribution (Fx, Fy)
</h3>
<MatchAnalysisForceChart points={analysis.forcePoints} />
</div>
<div className="space-y-6">
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Text emotes
</h3>
<MatchAnalysisEmoteChart
rows={analysis.textEmotes}
title="Text emotes"
barClassName="fill-violet-500 dark:fill-violet-400"
/>
</div>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Emoji emotes
</h3>
<MatchAnalysisEmoteChart
rows={analysis.emojiEmotes}
title="Emoji emotes"
barClassName="fill-amber-500 dark:fill-amber-400"
labelWidth={72}
labelMaxLen={12}
labelMonospace={false}
/>
</div>
</div>
</div>
{analysis.matchesWithLogFiles > 0 ? (
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Match completion (from logs)
</h3>
<p className="mt-2 text-sm text-zinc-700 dark:text-zinc-300">
Winner logged:{" "}
<span className="font-mono font-semibold tabular-nums">
{analysis.matchesWithWinnerPatch.toLocaleString("en-US")}
</span>
{" / "}
<span className="font-mono tabular-nums">
{analysis.matchesWithLogFiles.toLocaleString("en-US")}
</span>
{winnerRatioPct != null ? (
<>
{" "}
(
<span className="font-mono tabular-nums">
{winnerRatioPct.toLocaleString("en-US", {
maximumFractionDigits: 1,
})}
%
</span>
)
</>
) : null}
</p>
<p className="mt-1 text-[11px] text-zinc-500 dark:text-zinc-400">
A match ended when its log contains{" "}
<span className="font-mono">
Dedicated match winner PATCH success
</span>
.
</p>
</div>
{analysis.notEndedMatchIds.length > 0 ? (
<AnalysisMatchAlertBox
category="notEnded"
matchIds={analysis.notEndedMatchIds}
title="Not ended"
description="Log file present but no winner PATCH line — the match did not finish normally."
/>
) : (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Every match with a log file includes a winner PATCH line.
</p>
)}
</section>
) : null}
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping latency analytics
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Visual summary of current ping health and worst latency countries in this filter.
</p>
</div>
{pingAnalytics.error ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
Failed to load ping reports: {pingAnalytics.error}
</div>
) : (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Stat
label="Ping reports"
value={reports.toLocaleString("en-US")}
/>
<Stat label="Average ping" value={fmtMs(avgPing)} />
<Stat label="P95 ping" value={fmtMs(p95Ping)} />
<Stat
label={`Bad ping >= ${badThreshold}ms`}
value={`${badReports.toLocaleString("en-US")} (${fmtPct(badRate)})`}
/>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping quality split
</h4>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<SplitBar
label={`Below ${badThreshold}ms`}
value={goodCount}
total={reports}
colorClassName="bg-emerald-500"
/>
<SplitBar
label={`${badThreshold}ms or above`}
value={badReports}
total={reports}
colorClassName="bg-rose-500"
/>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping breakdown
</h4>
<div className="inline-flex items-center gap-1 rounded-md border border-zinc-300 bg-white p-1 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setPingView("matches")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "matches"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By matches
</button>
<button
type="button"
onClick={() => setPingView("players")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "players"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By players
</button>
<button
type="button"
onClick={() => setPingView("countries")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "countries"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
By country
</button>
</div>
</div>
{pingRows.length === 0 ? (
<p className="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
No ping data for this filter.
</p>
) : (
<div className="mt-3 space-y-2">
{pingRows.map((row) => (
<PingEntryBar
key={row.key}
label={row.label}
minPing={row.minPing}
avgPing={row.avgPing}
maxPing={row.maxPing}
/>
))}
</div>
)}
</div>
</>
)}
</section>
<AnalysisGeolocationMap
userAccounts={geolocationAnalytics.userAccounts}
matches={geolocationAnalytics.matches}
error={geolocationAnalytics.error}
/>
</section>
);
}
function SplitBar({
label,
value,
total,
colorClassName,
}: {
label: string;
value: number;
total: number;
colorClassName: string;
}) {
const pct = total > 0 ? Math.max(0, Math.min(100, (value / total) * 100)) : 0;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-xs text-zinc-600 dark:text-zinc-300">
<span>{label}</span>
<span className="font-mono tabular-nums">
{value.toLocaleString("en-US")} ({pct.toLocaleString("en-US", { maximumFractionDigits: 1 })}%)
</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800">
<div
className={`h-2.5 rounded-full ${colorClassName}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function PingEntryBar({
label,
minPing,
avgPing,
maxPing,
}: {
label: string;
minPing: number;
avgPing: number;
maxPing: number;
}) {
const clamped = Math.max(0, Math.min(400, avgPing));
const widthPct = (clamped / 400) * 100;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-mono text-zinc-700 dark:text-zinc-300">{label}</span>
<span className="font-mono tabular-nums text-zinc-600 dark:text-zinc-400">
min {Math.round(minPing).toLocaleString("en-US")} ms · avg {Math.round(avgPing).toLocaleString("en-US")} ms · max {Math.round(maxPing).toLocaleString("en-US")} ms
</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800">
<div
className="h-2.5 rounded-full bg-amber-500"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
);
}
+144 -19
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import {
insertSetting,
deleteSetting,
updateSetting,
} from "@/app/actions/settings-actions";
import {
@@ -17,16 +18,31 @@ type Props = {
rows: DbSetting[];
saveError: boolean;
addError: string | null;
readOnly?: boolean;
};
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
function ReadOnlySettingRow({ row }: { row: DbSetting }) {
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="font-mono text-sm text-zinc-900 dark:text-zinc-100">
{row.key}
</p>
<p className="mt-2 break-all text-sm text-zinc-700 dark:text-zinc-300">
{row.value?.trim() ? row.value : "—"}
</p>
</div>
);
}
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
const [value, setValue] = useState(row.value ?? "");
const saveDisabled = value.trim() === "";
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form action={updateSetting}>
<input type="hidden" name="key" value={row.key} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
@@ -40,19 +56,37 @@ function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
id={`setting-value-${index}`}
name="value"
type="text"
defaultValue={row.value ?? ""}
value={value}
onChange={(e) => setValue(e.target.value)}
autoComplete="off"
className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
disabled={saveDisabled}
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
>
Save
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
</div>
);
}
@@ -64,10 +98,8 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
const [v, setV] = useState(initial);
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form action={updateSetting}>
<input type="hidden" name="key" value={row.key} />
<input type="hidden" name="value" value={String(v)} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
@@ -108,6 +140,22 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
</div>
);
}
@@ -123,16 +171,20 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
return rcToCoins(Number(t));
}, [rcText]);
const saveDisabled = rcText.trim() === "";
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
onSubmit={(e) => {
setLocalError(null);
const trimmed = rcText.trim();
if (trimmed === "") {
e.preventDefault();
setLocalError("Enter an RC value (one decimal place; tenths digit 03).");
setLocalError(
"Enter an RC value (one decimal place; tenths digit 03).",
);
return;
}
const rc = Number(trimmed);
@@ -174,13 +226,16 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
{localError ? (
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
<p
className="mt-2 text-sm text-red-600 dark:text-red-400"
role="alert"
>
{localError}
</p>
) : coinsPreview !== null ? (
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the
database.
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in
the database.
</p>
) : rcText.trim() !== "" ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
@@ -190,12 +245,29 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
</div>
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
disabled={saveDisabled}
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
>
Save
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Delete
</button>
</form>
</div>
);
}
@@ -213,7 +285,52 @@ export function AdminSettingsEditor({
rows,
saveError,
addError,
readOnly = false,
}: Props) {
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
if (readOnly) {
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
Settings are view-only for your account. Contact an admin to make
changes.
</div>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Existing keys
</h2>
{rows.length === 0 ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No settings rows yet.
</p>
) : (
<div className="space-y-3">
{rows.map((row) => (
<ReadOnlySettingRow key={row.key} row={row} />
))}
</div>
)}
</section>
<p className="text-center text-xs text-zinc-500 dark:text-zinc-400">
<Link
href="/"
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
scroll={false}
>
Back to dashboard
</Link>
</p>
</div>
);
}
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
{saveError ? (
@@ -255,6 +372,10 @@ export function AdminSettingsEditor({
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty key.
</p>
) : addError === "missingValue" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty value.
</p>
) : addError === "config" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Supabase admin client is not configured.
@@ -277,7 +398,8 @@ export function AdminSettingsEditor({
id="new-setting-key"
name="newKey"
type="text"
required
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
autoComplete="off"
placeholder="e.g. maintenance_message"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
@@ -294,6 +416,8 @@ export function AdminSettingsEditor({
id="new-setting-value"
name="newValue"
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
autoComplete="off"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
@@ -301,6 +425,7 @@ export function AdminSettingsEditor({
<div className="flex justify-end">
<button
type="submit"
disabled={newKey.trim() === "" || newValue.trim() === ""}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Add row
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useMemo, useState } from "react";
import { LocalTimestamp } from "@/components/local-timestamp";
import type { AuditLogEntry } from "@/lib/auth/audit-log";
import { pageAccessLabel } from "@/lib/auth/page-access-labels";
type Props = {
entries: AuditLogEntry[];
error: string | null;
};
const ACTION_LABELS: Record<string, string> = {
access: "Page access",
"auth.login": "Sign in",
"auth.logout": "Sign out",
"players.update_cc_rc": "Update player CC/RC",
"ledger.system_supply": "System supply",
"settings.update": "Update setting",
"settings.insert": "Create setting",
"settings.delete": "Delete setting",
"accounts.create": "Create account",
"accounts.update": "Update account",
"accounts.delete": "Delete account",
};
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action;
}
function isAuthAction(action: string): boolean {
return action === "auth.login" || action === "auth.logout";
}
function isAccessAction(action: string): boolean {
return action === "access";
}
/** Prefer top-level fields; fall back to details for older/partial entries. */
function entryIp(entry: AuditLogEntry): string | null {
if (entry.ip) return entry.ip;
const d = entry.details?.ip;
return typeof d === "string" ? d : null;
}
function entryDevice(entry: AuditLogEntry): string | null {
if (entry.device) return entry.device;
const d = entry.details?.device;
return typeof d === "string" ? d : null;
}
function entryUserAgent(entry: AuditLogEntry): string | null {
if (entry.userAgent) return entry.userAgent;
const d = entry.details?.userAgent;
return typeof d === "string" ? d : null;
}
function entryPage(entry: AuditLogEntry): string | null {
const d = entry.details?.page;
return typeof d === "string" ? d : null;
}
function matchesQuery(entry: AuditLogEntry, q: string): boolean {
if (!q) return true;
const hay = [
entry.username,
entry.action,
actionLabel(entry.action),
entry.summary,
entryIp(entry) ?? "",
entryDevice(entry) ?? "",
entryUserAgent(entry) ?? "",
entryPage(entry) ?? "",
entryPage(entry) ? pageAccessLabel(entryPage(entry)!) : "",
entry.details ? JSON.stringify(entry.details) : "",
]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
export function AdminSystemLogs({ entries, error }: Props) {
const [query, setQuery] = useState("");
const [actionFilter, setActionFilter] = useState("");
const [accountFilter, setAccountFilter] = useState("");
const actionOptions = useMemo(() => {
const set = new Set<string>();
for (const e of entries) set.add(e.action);
for (const known of Object.keys(ACTION_LABELS)) set.add(known);
return [...set].sort((a, b) =>
actionLabel(a).localeCompare(actionLabel(b)),
);
}, [entries]);
const accountOptions = useMemo(() => {
const set = new Set<string>();
for (const e of entries) {
if (e.username) set.add(e.username);
}
return [...set].sort((a, b) => a.localeCompare(b));
}, [entries]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return entries.filter((entry) => {
if (actionFilter && entry.action !== actionFilter) return false;
if (accountFilter && entry.username !== accountFilter) return false;
return matchesQuery(entry, q);
});
}, [entries, query, actionFilter, accountFilter]);
const filtersActive = Boolean(query.trim() || actionFilter || accountFilter);
return (
<section className="mx-auto w-full max-w-[1400px] space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
System logs
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Panel actions, page access, and who performed them including login
IP and device. Newest first (up to 1,000 entries).
</p>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-[200px] flex-1">
<label
htmlFor="logs-search"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Search
</label>
<input
id="logs-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search summary, IP, device…"
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
/>
</div>
<div className="w-full sm:w-52">
<label
htmlFor="logs-action"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Action
</label>
<select
id="logs-action"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
>
<option value="">All actions</option>
{actionOptions.map((action) => (
<option key={action} value={action}>
{actionLabel(action)}
</option>
))}
</select>
</div>
<div className="w-full sm:w-44">
<label
htmlFor="logs-account"
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
>
Account
</label>
<select
id="logs-account"
value={accountFilter}
onChange={(e) => setAccountFilter(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none ring-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-50"
>
<option value="">All accounts</option>
{accountOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
{filtersActive ? (
<button
type="button"
onClick={() => {
setQuery("");
setActionFilter("");
setAccountFilter("");
}}
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Clear
</button>
) : null}
</div>
{error ? (
<div
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
{error}
</div>
) : null}
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filtered.length.toLocaleString("en-US")} of{" "}
{entries.length.toLocaleString("en-US")}
{filtersActive ? " (filtered)" : ""}
</p>
{entries.length === 0 && !error ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-10 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No actions logged yet.
</p>
) : filtered.length === 0 ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-10 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No log entries match these filters.
</p>
) : (
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="w-full min-w-[900px] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500 dark:border-zinc-800 dark:bg-zinc-950/50 dark:text-zinc-400">
<th className="px-4 py-3 font-medium">When</th>
<th className="px-4 py-3 font-medium">Who</th>
<th className="px-4 py-3 font-medium">Action</th>
<th className="px-4 py-3 font-medium">Details</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">Device</th>
</tr>
</thead>
<tbody>
{filtered.map((entry) => {
const ip = entryIp(entry);
const device = entryDevice(entry);
const userAgent = entryUserAgent(entry);
const page = entryPage(entry);
const showClientMeta =
isAuthAction(entry.action) ||
isAccessAction(entry.action) ||
Boolean(ip || device);
return (
<tr
key={entry.id}
className="border-b border-zinc-100 last:border-0 dark:border-zinc-800"
>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-zinc-700 dark:text-zinc-300">
<LocalTimestamp value={entry.at} />
</td>
<td className="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-50">
{entry.username}
</td>
<td className="px-4 py-3 text-zinc-700 dark:text-zinc-300">
<span className="font-medium">
{actionLabel(entry.action)}
</span>
<span className="mt-0.5 block font-mono text-xs text-zinc-500 dark:text-zinc-400">
{entry.action}
{page ? ` · ${page}` : ""}
</span>
</td>
<td className="max-w-md px-4 py-3 text-zinc-700 dark:text-zinc-300">
<p>{entry.summary}</p>
{entry.details &&
Object.keys(entry.details).length > 0 &&
!isAuthAction(entry.action) &&
!isAccessAction(entry.action) ? (
<pre className="mt-1 max-h-24 overflow-auto rounded bg-zinc-50 px-2 py-1 font-mono text-[11px] text-zinc-600 dark:bg-zinc-950 dark:text-zinc-400">
{JSON.stringify(entry.details, null, 0)}
</pre>
) : null}
{(isAuthAction(entry.action) ||
isAccessAction(entry.action)) &&
userAgent ? (
<p
className="mt-1 break-all font-mono text-[11px] text-zinc-500 dark:text-zinc-400"
title={userAgent}
>
{userAgent}
</p>
) : null}
</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{showClientMeta ? (ip ?? "—") : "—"}
</td>
<td className="max-w-[220px] px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">
{showClientMeta ? (device ?? "—") : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}
+360
View File
@@ -0,0 +1,360 @@
"use client";
import { useMemo, useState } from "react";
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from "react-simple-maps";
import {
countryDisplayName,
heatmapColorForCount,
NO_DATA_FILL,
NO_DATA_FILL_DARK,
} from "@/lib/heatmap-color";
import type { GeolocationCountryStat } from "@/lib/geolocation-analytics";
const GEO_URL =
"https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson";
type MapMode = "userAccounts" | "matches";
type Props = {
userAccounts: GeolocationCountryStat[];
matches: GeolocationCountryStat[];
error: string | null;
};
type TooltipState = {
countryCode: string;
x: number;
y: number;
} | null;
function countryCodeFromGeo(geo: {
properties?: Record<string, unknown>;
id?: string | number;
}): string | null {
const props = geo.properties ?? {};
const candidates = [
props.ISO_A2,
props.iso_a2,
props.ISO3166_1_Alpha_2,
props["ISO3166-1-Alpha-2"],
geo.id,
];
for (const candidate of candidates) {
const code = String(candidate ?? "")
.trim()
.toUpperCase();
if (/^[A-Z]{2}$/.test(code)) return code;
}
return null;
}
function buildCountMap(rows: GeolocationCountryStat[]): Map<string, GeolocationCountryStat> {
const map = new Map<string, GeolocationCountryStat>();
for (const row of rows) {
map.set(row.countryCode.toUpperCase(), row);
}
return map;
}
function countRange(rows: GeolocationCountryStat[]): {
min: number;
max: number;
} {
if (rows.length === 0) return { min: 0, max: 0 };
const counts = rows.map((r) => r.count);
return { min: Math.min(...counts), max: Math.max(...counts) };
}
export function AnalysisGeolocationMap({
userAccounts,
matches,
error,
}: Props) {
const [mode, setMode] = useState<MapMode>("userAccounts");
const [tooltip, setTooltip] = useState<TooltipState>(null);
const activeRows = mode === "userAccounts" ? userAccounts : matches;
const countByCountry = useMemo(() => buildCountMap(activeRows), [activeRows]);
const { min: minCount, max: maxCount } = useMemo(
() => countRange(activeRows),
[activeRows],
);
const totalCount = activeRows.reduce((acc, row) => acc + row.count, 0);
const countriesWithData = activeRows.length;
const tooltipStat = tooltip
? countByCountry.get(tooltip.countryCode.toUpperCase())
: null;
return (
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Player geolocation
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
World heatmap by country blue is fewest, red is most. Gray means no
players in that country.
</p>
</div>
<div className="inline-flex items-center gap-1 rounded-md border border-zinc-300 bg-white p-1 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setMode("userAccounts")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
mode === "userAccounts"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
User accounts
</button>
<button
type="button"
onClick={() => setMode("matches")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
mode === "matches"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
Matches
</button>
</div>
</div>
{error ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
Failed to load geolocation data: {error}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
<StatPill
label={mode === "userAccounts" ? "Users mapped" : "Matches mapped"}
value={totalCount.toLocaleString("en-US")}
/>
<StatPill
label="Countries"
value={countriesWithData.toLocaleString("en-US")}
/>
<StatPill
label="Range per country"
value={
maxCount > 0
? `${minCount.toLocaleString("en-US")}${maxCount.toLocaleString("en-US")}`
: "—"
}
/>
</div>
<div className="relative overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950/40">
<ComposableMap
projection="geoEqualEarth"
projectionConfig={{ scale: 145 }}
width={800}
height={420}
style={{ width: "100%", height: "auto" }}
>
<ZoomableGroup center={[0, 10]} zoom={1} minZoom={1} maxZoom={4}>
<Geographies geography={GEO_URL}>
{({ geographies }) =>
geographies.map((geo) => {
const countryCode = countryCodeFromGeo(geo);
const stat = countryCode
? countByCountry.get(countryCode)
: undefined;
const count = stat?.count ?? 0;
const fill =
count > 0
? heatmapColorForCount(count, minCount, maxCount)
: undefined;
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={fill ?? NO_DATA_FILL}
stroke="#a1a1aa"
strokeWidth={0.25}
style={{
default: {
outline: "none",
transition: "fill 150ms ease",
},
hover: {
outline: "none",
fill:
fill ??
(typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
? NO_DATA_FILL_DARK
: NO_DATA_FILL),
filter: count > 0 ? "brightness(1.08)" : "none",
cursor: countryCode ? "pointer" : "default",
},
pressed: { outline: "none" },
}}
onMouseEnter={(event) => {
if (!countryCode) return;
setTooltip({
countryCode,
x: event.clientX,
y: event.clientY,
});
}}
onMouseMove={(event) => {
if (!countryCode) return;
setTooltip({
countryCode,
x: event.clientX,
y: event.clientY,
});
}}
onMouseLeave={() => setTooltip(null)}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
{tooltip && tooltipStat ? (
<div
className="pointer-events-none fixed z-50 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">
{countryDisplayName(tooltip.countryCode)}{" "}
<span className="font-mono text-zinc-500">({tooltip.countryCode})</span>
</p>
<p className="mt-1 text-zinc-700 dark:text-zinc-300">
{mode === "userAccounts" ? "Users" : "Matches"}:{" "}
<span className="font-mono tabular-nums">
{tooltipStat.count.toLocaleString("en-US")}
</span>
</p>
{mode === "matches" && tooltipStat.avgPing != null ? (
<p className="text-zinc-700 dark:text-zinc-300">
Avg ping:{" "}
<span className="font-mono tabular-nums">
{Math.round(tooltipStat.avgPing).toLocaleString("en-US")} ms
</span>
</p>
) : null}
</div>
) : tooltip ? (
<div
className="pointer-events-none fixed z-50 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">
{countryDisplayName(tooltip.countryCode)}{" "}
<span className="font-mono text-zinc-500">({tooltip.countryCode})</span>
</p>
<p className="mt-1 text-zinc-500">No data</p>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-zinc-600 dark:text-zinc-400">
<span>Low</span>
<div
className="h-2.5 flex-1 min-w-[8rem] rounded-full"
style={{
background:
"linear-gradient(to right, hsl(220, 68%, 42%), hsl(120, 68%, 42%), hsl(0, 68%, 42%))",
}}
/>
<span>High</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm border border-zinc-300 dark:border-zinc-600"
style={{ backgroundColor: NO_DATA_FILL }}
/>
No players
</span>
</div>
{activeRows.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-50 text-zinc-500 dark:bg-zinc-950/60 dark:text-zinc-400">
<tr>
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
Country
</th>
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
{mode === "userAccounts" ? "Users" : "Matches"}
</th>
{mode === "matches" ? (
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
Avg ping
</th>
) : null}
</tr>
</thead>
<tbody>
{activeRows.slice(0, 12).map((row) => (
<tr
key={row.countryCode}
className="border-t border-zinc-200 dark:border-zinc-800"
>
<td className="px-3 py-2 text-zinc-800 dark:text-zinc-200">
{countryDisplayName(row.countryCode)}{" "}
<span className="font-mono text-zinc-500">
({row.countryCode})
</span>
</td>
<td className="px-3 py-2 font-mono tabular-nums">
{row.count.toLocaleString("en-US")}
</td>
{mode === "matches" ? (
<td className="px-3 py-2 font-mono tabular-nums">
{row.avgPing != null
? `${Math.round(row.avgPing).toLocaleString("en-US")} ms`
: "—"}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No geolocation data for this mode and filter.
</p>
)}
</section>
);
}
function StatPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50/60 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950/40">
<p className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{label}
</p>
<p className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
{value}
</p>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import Link from "next/link";
import {
useCallback,
useEffect,
useMemo,
useState,
type MouseEvent,
type ReactNode,
} from "react";
import {
EMPTY_HIDDEN_MIRROR,
EMPTY_HIDDEN_NOT_ENDED,
getHiddenAnalysisMatchIds,
hideAnalysisMatchIds,
subscribeAnalysisHiddenAlerts,
type AnalysisAlertCategory,
} from "@/lib/analysis-alert-hide-cookies";
function CloseIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
className={className ?? "h-3.5 w-3.5"}
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
);
}
const STYLES: Record<
AnalysisAlertCategory,
{
box: string;
body: string;
chip: string;
dismiss: string;
headerDismiss: string;
}
> = {
mirror: {
box: "rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-950 dark:border-red-800 dark:bg-red-950/50 dark:text-red-100",
body: "text-red-800 dark:text-red-200/90",
chip: "inline-flex items-center gap-0.5 overflow-hidden rounded-md border border-red-400/80 bg-white shadow-sm dark:border-red-700 dark:bg-red-950/80",
dismiss:
"shrink-0 p-1 text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/80",
headerDismiss:
"shrink-0 rounded-md p-1.5 text-red-800 hover:bg-red-100 dark:text-red-100 dark:hover:bg-red-900/80",
},
notEnded: {
box: "rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100",
body: "text-amber-900/90 dark:text-amber-200/90",
chip: "inline-flex items-center gap-0.5 overflow-hidden rounded-md border border-amber-400/80 bg-white shadow-sm dark:border-amber-700 dark:bg-amber-950/80",
dismiss:
"shrink-0 p-1 text-amber-800 hover:bg-amber-100 dark:text-amber-100 dark:hover:bg-amber-900/80",
headerDismiss:
"shrink-0 rounded-md p-1.5 text-amber-900 hover:bg-amber-100 dark:text-amber-50 dark:hover:bg-amber-900/80",
},
};
function emptyHiddenFor(category: AnalysisAlertCategory): number[] {
return category === "mirror" ? EMPTY_HIDDEN_MIRROR : EMPTY_HIDDEN_NOT_ENDED;
}
function useHiddenMatchIds(category: AnalysisAlertCategory): number[] {
const [hiddenIds, setHiddenIds] = useState<number[]>(() =>
emptyHiddenFor(category),
);
useEffect(() => {
setHiddenIds(getHiddenAnalysisMatchIds(category));
return subscribeAnalysisHiddenAlerts(() => {
setHiddenIds(getHiddenAnalysisMatchIds(category));
});
}, [category]);
return hiddenIds;
}
type Props = {
category: AnalysisAlertCategory;
matchIds: number[];
title: string;
description: ReactNode;
};
export function AnalysisMatchAlertBox({
category,
matchIds,
title,
description,
}: Props) {
const hiddenIds = useHiddenMatchIds(category);
const hiddenSet = useMemo(() => new Set(hiddenIds), [hiddenIds]);
const visibleIds = useMemo(
() => matchIds.filter((id) => !hiddenSet.has(id)),
[matchIds, hiddenSet],
);
const hideOne = useCallback(
(e: MouseEvent<HTMLButtonElement>, matchId: number) => {
e.preventDefault();
e.stopPropagation();
hideAnalysisMatchIds(category, [matchId]);
},
[category],
);
const hideAll = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
hideAnalysisMatchIds(category, visibleIds);
},
[category, visibleIds],
);
if (matchIds.length === 0 || visibleIds.length === 0) return null;
const s = STYLES[category];
return (
<div className={s.box} role="alert">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium">
{title} ({visibleIds.length.toLocaleString("en-US")}{" "}
{visibleIds.length === 1 ? "match" : "matches"})
</p>
<p className={`mt-1 ${s.body}`}>{description}</p>
</div>
<button
type="button"
onClick={hideAll}
className={s.headerDismiss}
aria-label="Hide all matches in this list"
title="Hide all"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
<ul className="mt-3 flex flex-wrap gap-2">
{visibleIds.map((matchId) => (
<li key={matchId}>
<span className={s.chip}>
<Link
href={`/match-logs/${matchId}`}
className={`px-2.5 py-1 font-mono text-xs font-medium ${
category === "mirror"
? "text-red-900 dark:text-red-50"
: "text-amber-950 dark:text-amber-50"
}`}
>
Match {matchId}
</Link>
<button
type="button"
onClick={(e) => hideOne(e, matchId)}
className={s.dismiss}
aria-label={`Hide match ${matchId}`}
title="Hide"
>
<CloseIcon />
</button>
</span>
</li>
))}
</ul>
</div>
);
}
@@ -90,6 +90,8 @@ export function EditUserCcRcOverlay({
? "matchmaker"
: tab === "ledger"
? "ledger"
: tab === "analysis"
? "analysis"
: "dashboard"
}
/>
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useState } from "react";
import { formatLocalTimestamp } from "@/lib/local-date-range";
type Props = {
value: string | null;
className?: string;
};
/**
* Renders timestamps in the browser's local timezone.
* Avoids SSR/client hydration mismatch by filling in after mount.
*/
export function LocalTimestamp({ value, className }: Props) {
const [label, setLabel] = useState("—");
useEffect(() => {
setLabel(formatLocalTimestamp(value));
}, [value]);
if (!value) {
return <span className={className}></span>;
}
return (
<time dateTime={value} className={className} suppressHydrationWarning>
{label}
</time>
);
}
@@ -0,0 +1,96 @@
"use client";
import type { EmoteBarRow } from "@/lib/match-log-parser";
const ROW_H = 22;
const BAR_MAX_W = 180;
const PAD = 4;
type Props = {
rows: EmoteBarRow[];
/** e.g. "Text emotes" */
title: string;
barClassName?: string;
/** Wider labels for emoji glyphs + `[id]` (default 120). */
labelWidth?: number;
/** Max label characters before ellipsis (default 16). */
labelMaxLen?: number;
/** Use monospace for label text (default true). */
labelMonospace?: boolean;
};
export function MatchAnalysisEmoteChart({
rows,
title,
barClassName = "fill-violet-500 dark:fill-violet-400",
labelWidth = 120,
labelMaxLen = 16,
labelMonospace = true,
}: Props) {
if (rows.length === 0) {
return (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No {title.toLowerCase()} in range.
</p>
);
}
const max = Math.max(...rows.map((r) => r.count), 1);
const vbH = PAD * 2 + rows.length * ROW_H;
const vbW = labelWidth + BAR_MAX_W + 48;
return (
<figure aria-label={`${title} bar chart`}>
<svg
viewBox={`0 0 ${vbW} ${vbH}`}
className="h-auto w-full max-w-lg text-zinc-800 dark:text-zinc-200"
role="img"
>
{rows.map((row, i) => {
const y = PAD + i * ROW_H + ROW_H / 2;
const barW = (row.count / max) * BAR_MAX_W;
const label =
row.label.length > labelMaxLen
? `${row.label.slice(0, labelMaxLen - 1)}`
: row.label;
return (
<g key={row.label}>
<text
x={0}
y={y}
dominantBaseline="middle"
className={`fill-zinc-600 dark:fill-zinc-400 ${
labelMonospace ? "text-[9px]" : "text-[11px]"
}`}
style={
labelMonospace
? { fontFamily: "ui-monospace, monospace" }
: undefined
}
>
{label}
</text>
<rect
x={labelWidth}
y={y - 6}
width={barW}
height={12}
rx={2}
className={barClassName}
/>
<text
x={labelWidth + BAR_MAX_W + 6}
y={y}
dominantBaseline="middle"
className="fill-zinc-500 text-[9px] tabular-nums dark:fill-zinc-400"
style={{ fontFamily: "ui-monospace, monospace" }}
>
{row.count.toLocaleString("en-US")}
</text>
</g>
);
})}
</svg>
</figure>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useMemo, useState } from "react";
const VB = 320;
const PAD = 18;
type Point = { x: number; y: number };
function extent(values: number[]): { min: number; max: number } {
if (values.length === 0) return { min: -1, max: 1 };
let min = values[0]!;
let max = values[0]!;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
}
if (min === max) {
const pad = Math.abs(min) * 0.1 + 1;
return { min: min - pad, max: max + pad };
}
const margin = (max - min) * 0.05;
return { min: min - margin, max: max + margin };
}
type Props = {
points: Point[];
};
export function MatchAnalysisForceChart({ points }: Props) {
const [hover, setHover] = useState<Point | null>(null);
const { dots, xExt, yExt } = useMemo(() => {
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const xExt = extent(xs);
const yExt = extent(ys);
const inner = VB - PAD * 2;
const dots = points.map((p) => {
const tx =
xExt.max === xExt.min
? 0.5
: (p.x - xExt.min) / (xExt.max - xExt.min);
const ty =
yExt.max === yExt.min
? 0.5
: (p.y - yExt.min) / (yExt.max - yExt.min);
return {
cx: PAD + tx * inner,
cy: PAD + (1 - ty) * inner,
raw: p,
};
});
return { dots, xExt, yExt };
}, [points]);
if (points.length === 0) {
return (
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
No shot force vectors in the selected matches.
</p>
);
}
return (
<figure aria-label="Force vector scatter plot (x and y components)">
<div
className="relative cursor-crosshair"
onPointerLeave={() => setHover(null)}
>
<svg
viewBox={`0 0 ${VB} ${VB}`}
className="h-auto w-full max-w-md text-sky-600 dark:text-sky-400"
role="img"
onPointerMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const sx =
((e.clientX - rect.left) / rect.width) * VB;
const sy =
((e.clientY - rect.top) / rect.height) * VB;
let best: (typeof dots)[0] | null = null;
let bestD = 12;
for (const d of dots) {
const dist = Math.hypot(d.cx - sx, d.cy - sy);
if (dist < bestD) {
bestD = dist;
best = d;
}
}
setHover(best?.raw ?? null);
}}
>
<rect
x={PAD}
y={PAD}
width={VB - PAD * 2}
height={VB - PAD * 2}
className="fill-zinc-50 stroke-zinc-200 dark:fill-zinc-900/50 dark:stroke-zinc-700"
strokeWidth={1}
/>
<line
x1={PAD + (VB - PAD * 2) / 2}
y1={PAD}
x2={PAD + (VB - PAD * 2) / 2}
y2={VB - PAD}
className="stroke-zinc-200 dark:stroke-zinc-700"
strokeWidth={0.5}
strokeDasharray="3 3"
/>
<line
x1={PAD}
y1={PAD + (VB - PAD * 2) / 2}
x2={VB - PAD}
y2={PAD + (VB - PAD * 2) / 2}
className="stroke-zinc-200 dark:stroke-zinc-700"
strokeWidth={0.5}
strokeDasharray="3 3"
/>
{dots.map((d, i) => (
<circle
key={i}
cx={d.cx}
cy={d.cy}
r={hover === d.raw ? 3.5 : 2}
className="fill-current opacity-70"
/>
))}
</svg>
{hover ? (
<div
className="pointer-events-none absolute left-2 top-2 rounded-md border border-zinc-200 bg-white/95 px-2 py-1 text-[10px] font-mono tabular-nums shadow-sm dark:border-zinc-700 dark:bg-zinc-900/95"
role="status"
>
({hover.x.toFixed(1)}, {hover.y.toFixed(1)})
</div>
) : null}
</div>
<figcaption className="mt-2 flex justify-between gap-2 font-mono text-[10px] text-zinc-500 dark:text-zinc-400">
<span>
Fx {xExt.min.toFixed(0)} {xExt.max.toFixed(0)}
</span>
<span>
Fy {yExt.min.toFixed(0)} {yExt.max.toFixed(0)}
</span>
</figcaption>
<p className="mt-1 text-[10px] text-zinc-500 dark:text-zinc-400">
{points.length.toLocaleString("en-US")} shots · magnitude (x² + y²)
</p>
</figure>
);
}
+46 -37
View File
@@ -1,43 +1,23 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
type PlayerSide = {
id: number | null;
username: string | null;
cc: number | null;
rc: number | null;
color: "red" | "blue";
};
type Props = {
matchId: number;
statusLabel: string;
createdAtLabel: string;
/** `matches.entry_fee` as coin string (bigint); RC shown in footer. */
entryFeeCoins: string | null;
/** Ledger `entry_hold` / player as coin string; null if unknown. */
entryHoldPerPlayerCoins: string | null;
/** Preformatted `prize_cc` for display (e.g. with grouping). */
prizeCcLabel: string;
left: PlayerSide;
right: PlayerSide;
/** May be string when `bigint` is JSON-serialized. */
winnerId: number | string | null;
};
function rcLabelFromCoinString(s: string | null): string {
if (s == null || String(s).trim() === "") return "—";
/** Ledger rows are per player; both players pay → show `1.0 RC x 2 = 2.0 RC`. */
function rcPerPlayerTimesTwoLine(coinStr: string | null): string {
if (coinStr == null || String(coinStr).trim() === "") return "—";
try {
return formatRcLabelFromCoinsBigInt(BigInt(String(s).trim()));
const perPlayer = BigInt(String(coinStr).trim());
const bothPlayers = perPlayer * BigInt(2);
return `${formatRcLabelFromCoinsBigInt(perPlayer)} x 2 = ${formatRcLabelFromCoinsBigInt(bothPlayers)}`;
} catch {
return "—";
}
}
function entryTotalRcLabel(
function entryCombinedPerPlayerTimesTwoLine(
feeCoins: string | null,
holdCoins: string | null,
): string {
@@ -50,13 +30,39 @@ function entryTotalRcLabel(
return "—";
}
try {
const sum = BigInt(String(feeCoins).trim()) + BigInt(String(holdCoins).trim());
return formatRcLabelFromCoinsBigInt(sum);
const perPlayer =
BigInt(String(feeCoins).trim()) + BigInt(String(holdCoins).trim());
const bothPlayers = perPlayer * BigInt(2);
return `${formatRcLabelFromCoinsBigInt(perPlayer)} x 2 = ${formatRcLabelFromCoinsBigInt(bothPlayers)}`;
} catch {
return "—";
}
}
type PlayerSide = {
id: number | null;
username: string | null;
cc: number | null;
rc: number | null;
color: "red" | "blue";
};
type Props = {
matchId: number;
statusLabel: string;
createdAtLabel: ReactNode;
/** Ledger `entry_fee` / player as coin string; null if unknown. */
entryFeeCoins: string | null;
/** Ledger `entry_hold` / player as coin string; null if unknown. */
entryHoldPerPlayerCoins: string | null;
/** Preformatted `prize_cc` for display (e.g. with grouping). */
prizeCcLabel: string;
left: PlayerSide;
right: PlayerSide;
/** May be string when `bigint` is JSON-serialized. */
winnerId: number | string | null;
};
function idsMatch(
a: number | string | null | undefined,
b: number | string | null | undefined,
@@ -185,9 +191,12 @@ export function MatchHistoryBattleCard({
right,
winnerId,
}: Props) {
const entryRc = entryTotalRcLabel(entryFeeCoins, entryHoldPerPlayerCoins);
const entryFeeRc = rcLabelFromCoinString(entryFeeCoins);
const entryHoldRc = rcLabelFromCoinString(entryHoldPerPlayerCoins);
const entryLine = entryCombinedPerPlayerTimesTwoLine(
entryFeeCoins,
entryHoldPerPlayerCoins,
);
const entryHoldLine = rcPerPlayerTimesTwoLine(entryHoldPerPlayerCoins);
const entryFeeLine = rcPerPlayerTimesTwoLine(entryFeeCoins);
const prizeCcDisplay = prizeCcLabel.trim() === "" ? "—" : prizeCcLabel;
const hasWinner =
winnerId != null &&
@@ -238,16 +247,16 @@ export function MatchHistoryBattleCard({
Time {createdAtLabel}
</span>
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
Entry {entryRc}
Entry {entryLine}
</span>
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
Entry hold {entryHoldRc}
Entry hold {entryHoldLine}
</span>
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
Prize {prizeCcDisplay} CC
</span>
<span className="rounded-md border border-emerald-200/90 bg-emerald-50 px-2 py-1 text-[11px] font-medium text-emerald-600 dark:border-emerald-300/35 dark:bg-emerald-400/15 dark:text-emerald-200">
Entry fee {entryFeeRc}
<span className="rounded-md border border-emerald-200/90 bg-emerald-50 px-2 py-1 text-[11px] font-medium text-emerald-600 tabular-nums dark:border-emerald-300/35 dark:bg-emerald-400/15 dark:text-emerald-200">
Entry fee {entryFeeLine}
</span>
</div>
<Link
+26
View File
@@ -16,6 +16,7 @@ import {
formatRcBalanceWithCoins,
formatRcLabelFromCoinsBigInt,
} from "@/lib/coins-rc";
import { countryFlagFromCode } from "@/lib/ip-geolocation";
import { usePlayerCard } from "@/components/player-card-context";
function formatTsUtc(value: string | null): string {
@@ -153,6 +154,12 @@ export function PlayerCardModal() {
</div>
{load.status === "ok" ? (
<div className="mt-3 grid gap-1 text-sm text-zinc-600 dark:text-zinc-400">
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Email</span>
<span className="truncate font-mono text-zinc-800 dark:text-zinc-200">
{load.data.email?.trim() ? load.data.email.trim() : "—"}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Account created</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
@@ -165,6 +172,25 @@ export function PlayerCardModal() {
{formatTsUtc(load.data.lastSeen)}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Last logged in IP</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{load.data.lastLoggedInIp?.trim()
? load.data.lastLoggedInIp.trim()
: "—"}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Country code</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{(() => {
const code = load.data.lastLoggedInCountryCode?.trim();
if (!code) return "—";
const flag = countryFlagFromCode(code);
return flag ? `${flag} ${code}` : code;
})()}
</span>
</div>
</div>
) : null}
</div>
+704
View File
@@ -0,0 +1,704 @@
"use client";
import {
useCallback,
useEffect,
useEffectEvent,
useRef,
useState,
} from "react";
import { parseReplayJsonText } from "@/lib/replay-parse";
import {
discontinuityTimes,
eventCursorAfter,
formatReplayTime,
resolvePoses,
scoreAtTime,
} from "@/lib/replay-playback";
import type { ReplayEvent, ReplayFile } from "@/types/replay";
import {
REPLAY_BOTTOM_GOAL,
REPLAY_FIELD_BOUNDS,
REPLAY_PITCH_BOUNDS,
REPLAY_TOP_GOAL,
type ReplayGoalBox,
} from "@/types/replay";
const SPEEDS = [0.25, 0.5, 1, 1.5, 2, 4] as const;
/** Padding around the field rect inside the canvas (CSS px). */
const VIEW_PAD = 12;
type ViewRect = {
ox: number;
oy: number;
w: number;
h: number;
};
/** Letterbox the fixed field into the canvas, preserving aspect. */
function fieldViewRect(canvasW: number, canvasH: number): ViewRect {
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
const worldW = maxX - minX;
const worldH = maxY - minY;
const availW = Math.max(1, canvasW - VIEW_PAD * 2);
const availH = Math.max(1, canvasH - VIEW_PAD * 2);
const scale = Math.min(availW / worldW, availH / worldH);
const w = worldW * scale;
const h = worldH * scale;
return {
ox: (canvasW - w) / 2,
oy: (canvasH - h) / 2,
w,
h,
};
}
function worldToCanvas(
x: number,
y: number,
view: ViewRect,
): { cx: number; cy: number } {
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
const worldW = maxX - minX;
const worldH = maxY - minY;
return {
cx: view.ox + ((x - minX) / worldW) * view.w,
cy: view.oy + ((maxY - y) / worldH) * view.h, // +Y up → canvas Y down
};
}
function fillWorldRect(
ctx: CanvasRenderingContext2D,
view: ViewRect,
x0: number,
y0: number,
x1: number,
y1: number,
fill: string,
) {
const a = worldToCanvas(x0, y0, view);
const b = worldToCanvas(x1, y1, view);
ctx.fillStyle = fill;
ctx.fillRect(
Math.min(a.cx, b.cx),
Math.min(a.cy, b.cy),
Math.abs(b.cx - a.cx),
Math.abs(b.cy - a.cy),
);
}
function strokeWorldRect(
ctx: CanvasRenderingContext2D,
view: ViewRect,
x0: number,
y0: number,
x1: number,
y1: number,
stroke: string,
lineWidth = 2,
) {
const a = worldToCanvas(x0, y0, view);
const b = worldToCanvas(x1, y1, view);
ctx.strokeStyle = stroke;
ctx.lineWidth = lineWidth;
ctx.strokeRect(
Math.min(a.cx, b.cx),
Math.min(a.cy, b.cy),
Math.abs(b.cx - a.cx),
Math.abs(b.cy - a.cy),
);
}
function goalExtents(g: ReplayGoalBox) {
const halfW = g.width / 2;
const halfH = g.height / 2;
return {
left: g.x - halfW,
right: g.x + halfW,
bottom: g.y - halfH,
top: g.y + halfH,
};
}
type Props = {
/** Optional preloaded replay (e.g. later: server file by match id). */
initialReplay?: ReplayFile | null;
initialFileName?: string | null;
};
function entityFill(type: string, team: string): string {
if (type === "ball") return "#f4f0e6";
if (team === "Red") return "#e5484d";
if (team === "Blue") return "#3b82f6";
return "#a1a1aa";
}
function entityStroke(type: string, team: string): string {
if (type === "ball") return "#c4b89a";
if (team === "Red") return "#9f1239";
if (team === "Blue") return "#1d4ed8";
return "#52525b";
}
function drawPitch(
ctx: CanvasRenderingContext2D,
canvasW: number,
canvasH: number,
view: ViewRect,
) {
const pitch = REPLAY_PITCH_BOUNDS;
const field = REPLAY_FIELD_BOUNDS;
const worldW = field.maxX - field.minX;
const worldH = field.maxY - field.minY;
ctx.fillStyle = "#0d1117";
ctx.fillRect(0, 0, canvasW, canvasH);
// Playable pitch + goal boxes (view letterboxes to include goals)
fillWorldRect(
ctx,
view,
pitch.minX,
pitch.minY,
pitch.maxX,
pitch.maxY,
"#1a3d2b",
);
const topG = goalExtents(REPLAY_TOP_GOAL);
const botG = goalExtents(REPLAY_BOTTOM_GOAL);
fillWorldRect(
ctx,
view,
topG.left,
pitch.maxY,
topG.right,
topG.top,
"#1a3d2b",
);
fillWorldRect(
ctx,
view,
botG.left,
botG.bottom,
botG.right,
pitch.minY,
"#1a3d2b",
);
// Subtle horizontal stripes on the playable pitch
const stripes = 10;
for (let i = 0; i < stripes; i++) {
if (i % 2 === 0) continue;
const y0 =
pitch.maxY - ((pitch.maxY - pitch.minY) / stripes) * (i + 1);
const y1 = pitch.maxY - ((pitch.maxY - pitch.minY) / stripes) * i;
fillWorldRect(
ctx,
view,
pitch.minX,
y0,
pitch.maxX,
y1,
"rgba(255,255,255,0.03)",
);
}
const strokeLine = (
x0: number,
y0: number,
x1: number,
y1: number,
width = 2,
) => {
const a = worldToCanvas(x0, y0, view);
const b = worldToCanvas(x1, y1, view);
ctx.beginPath();
ctx.moveTo(a.cx, a.cy);
ctx.lineTo(b.cx, b.cy);
ctx.strokeStyle = "rgba(255,255,255,0.4)";
ctx.lineWidth = width;
ctx.stroke();
};
// Sidelines
strokeLine(pitch.minX, pitch.minY, pitch.minX, pitch.maxY, 2.5);
strokeLine(pitch.maxX, pitch.minY, pitch.maxX, pitch.maxY, 2.5);
// Top end: end-line with goal mouth notch
strokeLine(pitch.minX, pitch.maxY, topG.left, pitch.maxY, 2.5);
strokeLine(topG.right, pitch.maxY, pitch.maxX, pitch.maxY, 2.5);
strokeLine(topG.left, pitch.maxY, topG.left, topG.top, 2.5);
strokeLine(topG.right, pitch.maxY, topG.right, topG.top, 2.5);
strokeLine(topG.left, topG.top, topG.right, topG.top, 2.5);
// Bottom end: end-line with goal mouth notch
strokeLine(pitch.minX, pitch.minY, botG.left, pitch.minY, 2.5);
strokeLine(botG.right, pitch.minY, pitch.maxX, pitch.minY, 2.5);
strokeLine(botG.left, pitch.minY, botG.left, botG.bottom, 2.5);
strokeLine(botG.right, pitch.minY, botG.right, botG.bottom, 2.5);
strokeLine(botG.left, botG.bottom, botG.right, botG.bottom, 2.5);
// Center line + circle
strokeLine(pitch.minX, 0, pitch.maxX, 0, 2);
const r = Math.min(worldW, worldH) * 0.14;
const c = worldToCanvas(0, 0, view);
const rx = (r / worldW) * view.w;
const ry = (r / worldH) * view.h;
ctx.beginPath();
ctx.ellipse(c.cx, c.cy, rx, ry, 0, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.35)";
ctx.lineWidth = 2;
ctx.stroke();
// Goal fills (exact size/position)
fillWorldRect(
ctx,
view,
topG.left,
topG.bottom,
topG.right,
topG.top,
"rgba(229, 72, 77, 0.45)",
);
strokeWorldRect(
ctx,
view,
topG.left,
topG.bottom,
topG.right,
topG.top,
"rgba(229, 72, 77, 0.85)",
1.5,
);
fillWorldRect(
ctx,
view,
botG.left,
botG.bottom,
botG.right,
botG.top,
"rgba(59, 130, 246, 0.45)",
);
strokeWorldRect(
ctx,
view,
botG.left,
botG.bottom,
botG.right,
botG.top,
"rgba(59, 130, 246, 0.85)",
1.5,
);
}
function drawEntities(
ctx: CanvasRenderingContext2D,
replay: ReplayFile,
poses: { x: number; y: number }[],
view: ViewRect,
) {
const { minX, maxX, minY, maxY } = REPLAY_FIELD_BOUNDS;
const worldW = maxX - minX;
const worldH = maxY - minY;
const unit = Math.min(view.w / worldW, view.h / worldH);
const ballR = Math.max(4, unit * 0.12);
const puckR = Math.max(6, unit * 0.18);
for (let i = 0; i < replay.entities.length; i++) {
const ent = replay.entities[i]!;
const pose = poses[i];
if (!pose) continue;
const { cx, cy } = worldToCanvas(pose.x, pose.y, view);
const r = ent.type === "ball" ? ballR : puckR;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = entityFill(ent.type, ent.team);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = entityStroke(ent.type, ent.team);
ctx.stroke();
if (ent.type === "ball") {
ctx.beginPath();
ctx.arc(cx, cy, r * 0.35, 0, Math.PI * 2);
ctx.fillStyle = "rgba(0,0,0,0.12)";
ctx.fill();
}
}
}
function eventMarkerColor(e: ReplayEvent): string {
if (e.type === "goal") {
return e.team === "Red" ? "#3b82f6" : "#e5484d";
}
if (e.type === "reset") return "#a1a1aa";
return "transparent";
}
export function ReplayViewer({
initialReplay = null,
initialFileName = null,
}: Props) {
const [replay, setReplay] = useState<ReplayFile | null>(initialReplay);
const [fileName, setFileName] = useState<string | null>(initialFileName);
const [error, setError] = useState<string | null>(null);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(1);
const [time, setTime] = useState(0);
const [score, setScore] = useState({ red: 0, blue: 0 });
const [dragOver, setDragOver] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cutsRef = useRef<number[]>([]);
const timeRef = useRef(0);
const playingRef = useRef(false);
const speedRef = useRef(1);
const lastTsRef = useRef<number | null>(null);
const eventCursorRef = useRef(0);
const replayRef = useRef<ReplayFile | null>(null);
useEffect(() => {
timeRef.current = time;
}, [time]);
useEffect(() => {
playingRef.current = playing;
}, [playing]);
useEffect(() => {
speedRef.current = speed;
}, [speed]);
useEffect(() => {
replayRef.current = replay;
}, [replay]);
const loadReplay = useCallback((file: ReplayFile, name: string) => {
setReplay(file);
setFileName(name);
setError(null);
setTime(0);
timeRef.current = 0;
setPlaying(false);
setScore({ red: 0, blue: 0 });
cutsRef.current = discontinuityTimes(file.events);
eventCursorRef.current = 0;
}, []);
const onFileText = useCallback(
(text: string, name: string) => {
try {
const parsed = parseReplayJsonText(text);
loadReplay(parsed, name);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to parse replay");
setReplay(null);
setFileName(null);
}
},
[loadReplay],
);
const onPickFile = useCallback(
(file: File | null) => {
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = typeof reader.result === "string" ? reader.result : "";
onFileText(text, file.name);
};
reader.onerror = () => setError("Could not read file");
reader.readAsText(file);
},
[onFileText],
);
const seekTo = useCallback((t: number) => {
const r = replayRef.current;
if (!r) return;
const clamped = Math.min(Math.max(0, t), r.duration);
timeRef.current = clamped;
setTime(clamped);
setScore(scoreAtTime(r.events, clamped));
eventCursorRef.current = eventCursorAfter(r.events, clamped);
}, []);
const paint = useEffectEvent(() => {
const canvas = canvasRef.current;
const r = replayRef.current;
if (!canvas || !r) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
if (cssW < 2 || cssH < 2) return;
const needW = Math.floor(cssW * dpr);
const needH = Math.floor(cssH * dpr);
if (canvas.width !== needW || canvas.height !== needH) {
canvas.width = needW;
canvas.height = needH;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const view = fieldViewRect(cssW, cssH);
const poses = resolvePoses(
r.frames,
r.entities.length,
timeRef.current,
cutsRef.current,
);
drawPitch(ctx, cssW, cssH, view);
drawEntities(ctx, r, poses, view);
});
useEffect(() => {
let raf = 0;
const tick = (ts: number) => {
const r = replayRef.current;
if (r && playingRef.current) {
if (lastTsRef.current == null) lastTsRef.current = ts;
const dt = (ts - lastTsRef.current) / 1000;
lastTsRef.current = ts;
let next = timeRef.current + dt * speedRef.current;
if (next >= r.duration) {
next = r.duration;
playingRef.current = false;
setPlaying(false);
}
// Fire events crossed this frame (SFX later; update score for goals)
const events = r.events;
let cursor = eventCursorRef.current;
while (cursor < events.length && events[cursor]!.t <= next) {
const e = events[cursor]!;
if (e.type === "goal") {
setScore({ red: e.redScore, blue: e.blueScore });
}
cursor++;
}
eventCursorRef.current = cursor;
timeRef.current = next;
setTime(next);
} else {
lastTsRef.current = null;
}
paint();
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [paint]);
const goalEvents =
replay?.events.filter((e) => e.type === "goal") ?? [];
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{!replay ? (
<label
className={[
"flex flex-1 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-16 text-center transition",
dragOver
? "border-emerald-500 bg-emerald-500/10"
: "border-zinc-600 bg-zinc-900/40 hover:border-zinc-400 hover:bg-zinc-900/70",
].join(" ")}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) onPickFile(f);
}}
>
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
/>
<p className="text-base font-medium text-zinc-100">
Drop a replay JSON file here
</p>
<p className="mt-2 max-w-md text-sm text-zinc-400">
From the game server:{" "}
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-zinc-300">
Logs/&#123;matchId&#125;_replay.json
</code>
</p>
<span className="mt-6 rounded-lg border border-zinc-500 bg-zinc-800 px-4 py-2 text-sm font-medium text-zinc-100">
Choose file
</span>
{error ? (
<p className="mt-4 max-w-lg text-sm text-red-400">{error}</p>
) : null}
</label>
) : (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-mono text-sm text-zinc-300">
{fileName ?? `match_${replay.matchId}_replay.json`}
</p>
<p className="mt-0.5 text-xs text-zinc-500">
Match {replay.matchId}
{replay.isPractice ? " · practice" : ""}
{" · "}
{replay.entities.length} entities
{" · "}
{replay.frames.length.toLocaleString("en-US")} samples
{" · "}
v{replay.version}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-sm tabular-nums">
<span className="text-[#e5484d]">{score.red}</span>
<span className="text-zinc-500"></span>
<span className="text-[#3b82f6]">{score.blue}</span>
</div>
<label className="cursor-pointer rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-700">
Load other
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
/>
</label>
</div>
</div>
{error ? (
<p className="text-sm text-red-400">{error}</p>
) : null}
<div className="relative min-h-[280px] flex-1 overflow-hidden rounded-xl border border-zinc-700 bg-[#0d1117] shadow-inner">
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full"
aria-label="Replay field"
/>
</div>
<div className="space-y-3 rounded-xl border border-zinc-700 bg-[#161b22] p-3">
<div className="relative px-1 pt-3">
{/* Event markers */}
<div className="pointer-events-none absolute inset-x-1 top-0 h-3">
{goalEvents.map((e, i) => (
<span
key={`${e.t}-${i}`}
title={`Goal @ ${formatReplayTime(e.t)}`}
className="absolute top-0 h-2.5 w-1 -translate-x-1/2 rounded-sm"
style={{
left: `${replay.duration > 0 ? (e.t / replay.duration) * 100 : 0}%`,
backgroundColor: eventMarkerColor(e),
}}
/>
))}
</div>
<input
type="range"
min={0}
max={replay.duration || 1}
step={0.01}
value={Math.min(time, replay.duration)}
onChange={(e) => {
setPlaying(false);
seekTo(Number(e.target.value));
}}
className="w-full accent-emerald-500"
aria-label="Replay timeline"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (timeRef.current >= (replay.duration || 0)) {
seekTo(0);
}
setPlaying((p) => !p);
}}
className="rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-100 hover:bg-zinc-700"
>
{playing ? "Pause" : "Play"}
</button>
<button
type="button"
onClick={() => {
setPlaying(false);
seekTo(0);
}}
className="rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-100 hover:bg-zinc-700"
>
Reset
</button>
<span className="ml-1 font-mono text-xs tabular-nums text-zinc-400">
{formatReplayTime(time)} / {formatReplayTime(replay.duration)}
</span>
<div className="ml-auto flex flex-wrap items-center gap-1">
<span className="mr-1 text-xs text-zinc-500">Speed</span>
{SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
className={[
"rounded px-2 py-1 font-mono text-xs",
speed === s
? "bg-emerald-600/30 text-emerald-300"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200",
].join(" ")}
>
{s}×
</button>
))}
</div>
</div>
{goalEvents.length > 0 ? (
<div className="flex flex-wrap gap-1.5 border-t border-zinc-800 pt-2">
<span className="mr-1 self-center text-[11px] uppercase tracking-wide text-zinc-500">
Goals
</span>
{goalEvents.map((e, i) => {
const scorer =
e.team === "Red"
? "Blue"
: e.team === "Blue"
? "Red"
: "?";
return (
<button
key={`${e.t}-jump-${i}`}
type="button"
onClick={() => {
setPlaying(false);
seekTo(Math.max(0, e.t - 0.05));
}}
className="rounded border border-zinc-700 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-300 hover:border-zinc-500"
>
{formatReplayTime(e.t)} · {scorer} ({e.redScore}
{e.blueScore})
</button>
);
})}
</div>
) : null}
</div>
</>
)}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
/** Client-side cookie storing per-admin dismissed analysis alert match ids. */
export const ANALYSIS_HIDDEN_ALERTS_COOKIE = "kk_analysis_hidden_alerts";
export type AnalysisAlertCategory = "mirror" | "notEnded";
type StoredHiddenAlerts = {
mirror: number[];
notEnded: number[];
};
const EMPTY_MIRROR: number[] = [];
const EMPTY_NOT_ENDED: number[] = [];
const MAX_IDS_PER_CATEGORY = 400;
const COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 365;
export const ANALYSIS_HIDDEN_ALERTS_EVENT = "kk-analysis-hidden-alerts-change";
/** Stable empty snapshots for useSyncExternalStore / initial state. */
export const EMPTY_HIDDEN_MIRROR = EMPTY_MIRROR;
export const EMPTY_HIDDEN_NOT_ENDED = EMPTY_NOT_ENDED;
let storeCache: {
cookieValue: string;
mirror: number[];
notEnded: number[];
} | null = null;
function notifyHiddenAlertsChanged(): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(ANALYSIS_HIDDEN_ALERTS_EVENT));
}
}
export function subscribeAnalysisHiddenAlerts(
onStoreChange: () => void,
): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(ANALYSIS_HIDDEN_ALERTS_EVENT, onStoreChange);
return () =>
window.removeEventListener(ANALYSIS_HIDDEN_ALERTS_EVENT, onStoreChange);
}
function parseIdList(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
const out: number[] = [];
for (const v of raw) {
const n = Number(v);
if (Number.isInteger(n) && n >= 1) out.push(n);
}
return [...new Set(out)].sort((a, b) => a - b);
}
function invalidateStoreCache(): void {
storeCache = null;
}
function readStored(): StoredHiddenAlerts {
if (typeof document === "undefined") {
return { mirror: EMPTY_MIRROR, notEnded: EMPTY_NOT_ENDED };
}
const match = document.cookie.match(
new RegExp(`(?:^|; )${ANALYSIS_HIDDEN_ALERTS_COOKIE}=([^;]*)`),
);
const cookieValue = match?.[1] ?? "";
if (storeCache?.cookieValue === cookieValue) {
return storeCache;
}
let mirror = EMPTY_MIRROR;
let notEnded = EMPTY_NOT_ENDED;
if (cookieValue) {
try {
const parsed = JSON.parse(decodeURIComponent(cookieValue)) as unknown;
if (parsed && typeof parsed === "object") {
const o = parsed as Record<string, unknown>;
const m = parseIdList(o.mirror);
const n = parseIdList(o.notEnded);
if (m.length > 0) mirror = m;
if (n.length > 0) notEnded = n;
}
} catch {
// ignore corrupt cookie
}
}
storeCache = { cookieValue, mirror, notEnded };
return storeCache;
}
function writeStored(data: StoredHiddenAlerts): void {
if (typeof document === "undefined") return;
const value = encodeURIComponent(JSON.stringify(data));
document.cookie = `${ANALYSIS_HIDDEN_ALERTS_COOKIE}=${value}; path=/; max-age=${COOKIE_MAX_AGE_SEC}; SameSite=Lax`;
invalidateStoreCache();
}
function trimIds(ids: number[]): number[] {
if (ids.length <= MAX_IDS_PER_CATEGORY) return ids;
return ids.slice(ids.length - MAX_IDS_PER_CATEGORY);
}
/** Match ids the admin dismissed for this alert category (stable array reference). */
export function getHiddenAnalysisMatchIds(
category: AnalysisAlertCategory,
): number[] {
const stored = readStored();
return category === "mirror" ? stored.mirror : stored.notEnded;
}
/** Add match ids to the hide list for a category (persists in cookie). */
export function hideAnalysisMatchIds(
category: AnalysisAlertCategory,
matchIds: number[],
): void {
const stored = readStored();
const set = new Set(stored[category]);
for (const id of matchIds) {
if (Number.isInteger(id) && id >= 1) set.add(id);
}
const next = trimIds([...set].sort((a, b) => a - b));
writeStored({
mirror: category === "mirror" ? next : stored.mirror,
notEnded: category === "notEnded" ? next : stored.notEnded,
});
notifyHiddenAlertsChanged();
}
+319
View File
@@ -0,0 +1,319 @@
import {
randomBytes,
randomUUID,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import {
fullPagePermissions,
normalizePagePermissions,
type PagePermissions,
type SessionAccount,
} from "@/lib/auth/permissions";
const SCRYPT_OPTS = {
N: 16384,
r: 8,
p: 1,
maxmem: 64 * 1024 * 1024,
} as const;
const HASH_KEYLEN = 64;
export type AdminAccountRecord = {
id: string;
username: string;
passwordHash: string;
isAdmin: boolean;
permissions: PagePermissions;
};
type AccountsFile = {
version: 1;
accounts: AdminAccountRecord[];
};
function accountsFilePath(): string {
const override = process.env.ADMIN_ACCOUNTS_PATH?.trim();
if (override) return path.resolve(override);
return path.join(process.cwd(), "data", "admin-accounts.json");
}
export function getBootstrapAdminUsername(): string {
return process.env.ADMIN_USERNAME?.trim() || "admin";
}
export function getBootstrapAdminPassword(): string | null {
const password = process.env.ADMIN_PASSWORD?.trim();
return password || null;
}
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, HASH_KEYLEN, SCRYPT_OPTS);
return `scrypt:${salt.toString("base64")}:${hash.toString("base64")}`;
}
export function verifyPassword(
password: string,
stored: string,
): boolean {
const parts = stored.split(":");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(parts[1]!, "base64");
expected = Buffer.from(parts[2]!, "base64");
} catch {
return false;
}
if (salt.length === 0 || expected.length === 0) return false;
const actual = scryptSync(password, salt, expected.length, SCRYPT_OPTS);
if (actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
function toSessionAccount(record: AdminAccountRecord): SessionAccount {
return {
id: record.id,
username: record.username,
isAdmin: record.isAdmin,
permissions: record.isAdmin
? fullPagePermissions()
: normalizePagePermissions(record.permissions),
};
}
function normalizeRecord(raw: unknown): AdminAccountRecord | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
if (typeof o.id !== "string" || !o.id) return null;
if (typeof o.username !== "string" || !o.username.trim()) return null;
if (typeof o.passwordHash !== "string" || !o.passwordHash) return null;
const isAdmin = Boolean(o.isAdmin);
const permissions = isAdmin
? fullPagePermissions()
: normalizePagePermissions(
o.permissions as Partial<
Record<string, { read?: boolean; write?: boolean }>
>,
);
return {
id: o.id,
username: o.username.trim(),
passwordHash: o.passwordHash,
isAdmin,
permissions,
};
}
async function readRawFile(): Promise<AccountsFile | null> {
const filePath = accountsFilePath();
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return null;
throw err;
}
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const accountsRaw = (parsed as { accounts?: unknown }).accounts;
if (!Array.isArray(accountsRaw)) return { version: 1, accounts: [] };
const accounts: AdminAccountRecord[] = [];
for (const item of accountsRaw) {
const rec = normalizeRecord(item);
if (rec) accounts.push(rec);
}
return { version: 1, accounts };
} catch {
return { version: 1, accounts: [] };
}
}
async function writeAccountsFile(accounts: AdminAccountRecord[]): Promise<void> {
const filePath = accountsFilePath();
await mkdir(path.dirname(filePath), { recursive: true });
const payload: AccountsFile = { version: 1, accounts };
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
await rename(tmp, filePath);
}
let writeChain: Promise<unknown> = Promise.resolve();
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
const next = writeChain.then(fn, fn);
writeChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
async function ensureAdminBootstrapped(
accounts: AdminAccountRecord[],
): Promise<AdminAccountRecord[]> {
if (accounts.some((a) => a.isAdmin)) return accounts;
const password = getBootstrapAdminPassword();
if (!password) {
throw new Error(
"ADMIN_PASSWORD is required to bootstrap the admin account.",
);
}
const username = getBootstrapAdminUsername();
const admin: AdminAccountRecord = {
id: randomUUID(),
username,
passwordHash: hashPassword(password),
isAdmin: true,
permissions: fullPagePermissions(),
};
// Drop any non-admin that collides with the bootstrap username.
const rest = accounts.filter(
(a) => a.username.toLowerCase() !== username.toLowerCase(),
);
const next = [admin, ...rest];
await writeAccountsFile(next);
return next;
}
export async function listAccounts(): Promise<AdminAccountRecord[]> {
const file = await readRawFile();
const accounts = await ensureAdminBootstrapped(file?.accounts ?? []);
return accounts;
}
export async function listSessionAccounts(): Promise<SessionAccount[]> {
const accounts = await listAccounts();
return accounts.map(toSessionAccount);
}
export async function findAccountByUsername(
username: string,
): Promise<AdminAccountRecord | null> {
const needle = username.trim().toLowerCase();
if (!needle) return null;
const accounts = await listAccounts();
return (
accounts.find((a) => a.username.toLowerCase() === needle) ?? null
);
}
export async function findAccountById(
id: string,
): Promise<AdminAccountRecord | null> {
const accounts = await listAccounts();
return accounts.find((a) => a.id === id) ?? null;
}
export async function verifyAccountCredentials(
username: string,
password: string,
): Promise<SessionAccount | null> {
const account = await findAccountByUsername(username);
if (!account) return null;
if (!verifyPassword(password, account.passwordHash)) return null;
return toSessionAccount(account);
}
export async function createAccount(input: {
username: string;
password: string;
permissions: PagePermissions;
}): Promise<{ ok: true; account: SessionAccount } | { ok: false; error: string }> {
const username = input.username.trim();
if (!username) return { ok: false, error: "missingUsername" };
if (!input.password) return { ok: false, error: "missingPassword" };
return enqueueWrite(async () => {
const accounts = await listAccounts();
if (
accounts.some((a) => a.username.toLowerCase() === username.toLowerCase())
) {
return { ok: false, error: "duplicate" };
}
const record: AdminAccountRecord = {
id: randomUUID(),
username,
passwordHash: hashPassword(input.password),
isAdmin: false,
permissions: normalizePagePermissions(input.permissions),
};
await writeAccountsFile([...accounts, record]);
return { ok: true, account: toSessionAccount(record) };
});
}
export async function updateAccount(input: {
id: string;
username?: string;
password?: string;
permissions?: PagePermissions;
}): Promise<{ ok: true; account: SessionAccount } | { ok: false; error: string }> {
return enqueueWrite(async () => {
const accounts = await listAccounts();
const idx = accounts.findIndex((a) => a.id === input.id);
if (idx < 0) return { ok: false, error: "notFound" };
const current = accounts[idx]!;
let username = current.username;
if (!current.isAdmin && input.username !== undefined) {
const nextName = input.username.trim();
if (!nextName) return { ok: false, error: "missingUsername" };
if (
accounts.some(
(a) =>
a.id !== current.id &&
a.username.toLowerCase() === nextName.toLowerCase(),
)
) {
return { ok: false, error: "duplicate" };
}
username = nextName;
}
let passwordHash = current.passwordHash;
if (input.password !== undefined && input.password !== "") {
passwordHash = hashPassword(input.password);
}
let permissions = current.permissions;
if (!current.isAdmin && input.permissions !== undefined) {
permissions = normalizePagePermissions(input.permissions);
} else if (current.isAdmin) {
permissions = fullPagePermissions();
}
const next: AdminAccountRecord = {
...current,
username,
passwordHash,
permissions,
};
const updated = [...accounts];
updated[idx] = next;
await writeAccountsFile(updated);
return { ok: true, account: toSessionAccount(next) };
});
}
export async function deleteAccount(
id: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
return enqueueWrite(async () => {
const accounts = await listAccounts();
const target = accounts.find((a) => a.id === id);
if (!target) return { ok: false, error: "notFound" };
if (target.isAdmin) return { ok: false, error: "cannotDeleteAdmin" };
await writeAccountsFile(accounts.filter((a) => a.id !== id));
return { ok: true };
});
}
+140
View File
@@ -0,0 +1,140 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
export type AuditLogEntry = {
id: string;
at: string;
username: string;
accountId: string | null;
action: string;
summary: string;
details?: Record<string, unknown>;
ip?: string | null;
/** Short device label (e.g. "Desktop · Windows · Chrome"). */
device?: string | null;
/** Raw User-Agent (truncated), mainly for login audits. */
userAgent?: string | null;
};
export type AuditLogInput = {
username: string;
accountId?: string | null;
action: string;
summary: string;
details?: Record<string, unknown>;
ip?: string | null;
device?: string | null;
userAgent?: string | null;
};
const DEFAULT_READ_LIMIT = 500;
function auditLogPath(): string {
const override = process.env.ADMIN_AUDIT_LOG_PATH?.trim();
if (override) return path.resolve(override);
return path.join(process.cwd(), "data", "admin-audit.jsonl");
}
let writeChain: Promise<unknown> = Promise.resolve();
function enqueueWrite<T>(fn: () => Promise<T>): Promise<T> {
const next = writeChain.then(fn, fn);
writeChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
/** Append one audit entry. Never throws to callers (best-effort). */
export async function appendAuditLog(
input: AuditLogInput,
): Promise<void> {
try {
await enqueueWrite(async () => {
const filePath = auditLogPath();
await mkdir(path.dirname(filePath), { recursive: true });
const entry: AuditLogEntry = {
id: randomUUID(),
at: new Date().toISOString(),
username: input.username,
accountId: input.accountId ?? null,
action: input.action,
summary: input.summary,
...(input.details ? { details: input.details } : {}),
...(input.ip != null ? { ip: input.ip } : {}),
...(input.device != null ? { device: input.device } : {}),
...(input.userAgent != null ? { userAgent: input.userAgent } : {}),
};
await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
});
} catch (err) {
console.error("[audit-log] failed to append", err);
}
}
function parseLine(line: string): AuditLogEntry | null {
const trimmed = line.trim();
if (!trimmed) return null;
try {
const raw = JSON.parse(trimmed) as Partial<AuditLogEntry>;
if (
typeof raw.id !== "string" ||
typeof raw.at !== "string" ||
typeof raw.username !== "string" ||
typeof raw.action !== "string" ||
typeof raw.summary !== "string"
) {
return null;
}
return {
id: raw.id,
at: raw.at,
username: raw.username,
accountId:
typeof raw.accountId === "string" ? raw.accountId : null,
action: raw.action,
summary: raw.summary,
...(raw.details && typeof raw.details === "object"
? { details: raw.details as Record<string, unknown> }
: {}),
...(typeof raw.ip === "string" || raw.ip === null
? { ip: raw.ip }
: {}),
...(typeof raw.device === "string" || raw.device === null
? { device: raw.device }
: {}),
...(typeof raw.userAgent === "string" || raw.userAgent === null
? { userAgent: raw.userAgent }
: {}),
};
} catch {
return null;
}
}
/** Newest-first. Caps at `limit` entries. */
export async function readAuditLog(
limit = DEFAULT_READ_LIMIT,
): Promise<AuditLogEntry[]> {
const filePath = auditLogPath();
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return [];
throw err;
}
const lines = text.split("\n");
const entries: AuditLogEntry[] = [];
for (let i = lines.length - 1; i >= 0; i--) {
const entry = parseLine(lines[i]!);
if (!entry) continue;
entries.push(entry);
if (entries.length >= limit) break;
}
return entries;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Best-effort device / browser label from a User-Agent string.
* Not for security decisions display and audit only.
*/
export function parseDeviceFromUserAgent(
userAgent: string | null | undefined,
): { device: string; userAgent: string | null } {
const ua = userAgent?.trim() || null;
if (!ua) {
return { device: "Unknown device", userAgent: null };
}
const os =
/Windows NT/i.test(ua)
? "Windows"
: /Android/i.test(ua)
? "Android"
: /iPhone|iPad|iPod/i.test(ua)
? "iOS"
: /Mac OS X|Macintosh/i.test(ua)
? "macOS"
: /CrOS/i.test(ua)
? "Chrome OS"
: /Linux/i.test(ua)
? "Linux"
: null;
const browser =
/Edg\//i.test(ua)
? "Edge"
: /OPR\/|Opera/i.test(ua)
? "Opera"
: /Firefox\//i.test(ua)
? "Firefox"
: /Chrome\//i.test(ua) && !/Edg\//i.test(ua)
? "Chrome"
: /Safari\//i.test(ua) && !/Chrome\//i.test(ua)
? "Safari"
: /curl\//i.test(ua)
? "curl"
: null;
const formFactor = /Mobile|Android.*Mobile|iPhone|iPod/i.test(ua)
? "Mobile"
: /iPad|Tablet|Android(?!.*Mobile)/i.test(ua)
? "Tablet"
: "Desktop";
const parts = [formFactor, os, browser].filter(Boolean);
return {
device: parts.length > 0 ? parts.join(" · ") : "Unknown device",
userAgent: ua.slice(0, 512),
};
}
export function getRequestUserAgent(request: Request): string | null {
const ua = request.headers.get("user-agent")?.trim();
return ua || null;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Best-effort client IP for rate limiting behind a reverse proxy.
* Falls back to a sentinel when unknown so attempts are still counted.
*/
export function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = request.headers.get("x-real-ip")?.trim();
if (realIp) return realIp;
return "unknown";
}
+9
View File
@@ -0,0 +1,9 @@
import { verifyAccountCredentials } from "@/lib/auth/accounts-store";
import type { SessionAccount } from "@/lib/auth/permissions";
export async function verifyCredentials(
username: string,
password: string,
): Promise<SessionAccount | null> {
return verifyAccountCredentials(username, password);
}
+47
View File
@@ -0,0 +1,47 @@
import { headers } from "next/headers";
import { appendAuditLog } from "@/lib/auth/audit-log";
import { parseDeviceFromUserAgent } from "@/lib/auth/client-device";
import { pageAccessLabel } from "@/lib/auth/page-access-labels";
import type { SessionAccount } from "@/lib/auth/permissions";
function clientIpFromHeaders(h: Headers): string {
const forwarded = h.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const realIp = h.get("x-real-ip")?.trim();
if (realIp) return realIp;
return "unknown";
}
/** Record that an account loaded a panel page. Best-effort; never throws. */
export async function logPageAccess(
account: SessionAccount,
page: string,
extraDetails?: Record<string, unknown>,
): Promise<void> {
try {
const h = await headers();
const ip = clientIpFromHeaders(h);
const { device, userAgent } = parseDeviceFromUserAgent(
h.get("user-agent"),
);
await appendAuditLog({
username: account.username,
accountId: account.id,
action: "access",
summary: `Accessed ${pageAccessLabel(page)}`,
ip,
device,
userAgent,
details: {
page,
...(extraDetails ?? {}),
},
});
} catch (err) {
console.error("[audit-log] page access failed", err);
}
}
+100
View File
@@ -0,0 +1,100 @@
import { NextResponse } from "next/server";
const MAX_FAILURES = 5;
const WINDOW_MS = 15 * 60 * 1000;
const LOCKOUT_MS = 15 * 60 * 1000;
type Entry = {
failures: number;
windowStartedAt: number;
lockedUntil: number | null;
};
const attemptsByIp = new Map<string, Entry>();
function getEntry(ip: string): Entry {
const existing = attemptsByIp.get(ip);
if (existing) return existing;
const entry: Entry = {
failures: 0,
windowStartedAt: Date.now(),
lockedUntil: null,
};
attemptsByIp.set(ip, entry);
return entry;
}
function resetWindowIfExpired(entry: Entry, now: number): void {
if (now - entry.windowStartedAt >= WINDOW_MS) {
entry.failures = 0;
entry.windowStartedAt = now;
if (entry.lockedUntil !== null && entry.lockedUntil <= now) {
entry.lockedUntil = null;
}
}
}
export type LoginRateLimitResult =
| { allowed: true }
| { allowed: false; retryAfterSeconds: number };
export function checkLoginRateLimit(ip: string): LoginRateLimitResult {
const now = Date.now();
const entry = getEntry(ip);
resetWindowIfExpired(entry, now);
if (entry.lockedUntil !== null && entry.lockedUntil > now) {
return {
allowed: false,
retryAfterSeconds: Math.ceil((entry.lockedUntil - now) / 1000),
};
}
if (entry.lockedUntil !== null && entry.lockedUntil <= now) {
entry.lockedUntil = null;
entry.failures = 0;
entry.windowStartedAt = now;
}
return { allowed: true };
}
export function recordFailedLogin(ip: string): void {
const now = Date.now();
const entry = getEntry(ip);
resetWindowIfExpired(entry, now);
entry.failures += 1;
if (entry.failures >= MAX_FAILURES) {
entry.lockedUntil = now + LOCKOUT_MS;
}
}
export function clearLoginAttempts(ip: string): void {
attemptsByIp.delete(ip);
}
export function rateLimitedLoginResponse(
request: Request,
contentType: string,
retryAfterSeconds: number,
publicRequestUrl: (request: Request, path: string) => URL,
): NextResponse {
const headers = { "Retry-After": String(retryAfterSeconds) };
if (contentType.includes("application/json")) {
return NextResponse.json(
{
error: "Too many failed login attempts. Try again later.",
retryAfterSeconds,
},
{ status: 429, headers },
);
}
return NextResponse.redirect(
publicRequestUrl(request, "/login?error=locked"),
{ headers },
);
}
+18
View File
@@ -0,0 +1,18 @@
const PAGE_LABELS: Record<string, string> = {
overview: "Overview",
players: "Players",
matches: "Matches",
analysis: "Analysis",
matchmaker: "Matchmaker",
ledger: "Ledger",
logs: "System logs",
settings: "Settings",
"ledger-book": "Ledger book",
"match-log": "Match log",
"matchmaker-logs": "Matchmaker logs",
"replay-viewer": "Replay viewer",
};
export function pageAccessLabel(page: string): string {
return PAGE_LABELS[page] ?? page;
}
+101
View File
@@ -0,0 +1,101 @@
export const PAGE_KEYS = [
"players",
"matches",
"analysis",
"matchmaker",
"ledger",
"logs",
] as const;
export type PageKey = (typeof PAGE_KEYS)[number];
/** Pages that only support read access (no write checkbox). */
export const READ_ONLY_PAGE_KEYS = ["logs"] as const satisfies readonly PageKey[];
export type PagePermission = {
read: boolean;
write: boolean;
};
export type PagePermissions = Record<PageKey, PagePermission>;
export function emptyPagePermissions(): PagePermissions {
return {
players: { read: false, write: false },
matches: { read: false, write: false },
analysis: { read: false, write: false },
matchmaker: { read: false, write: false },
ledger: { read: false, write: false },
logs: { read: false, write: false },
};
}
export function fullPagePermissions(): PagePermissions {
return {
players: { read: true, write: true },
matches: { read: true, write: true },
analysis: { read: true, write: true },
matchmaker: { read: true, write: true },
ledger: { read: true, write: true },
logs: { read: true, write: false },
};
}
/** Normalize permissions: write implies read. Logs never grants write. */
export function normalizePagePermissions(
input: Partial<Record<PageKey, Partial<PagePermission>>> | null | undefined,
): PagePermissions {
const base = emptyPagePermissions();
for (const key of PAGE_KEYS) {
const raw = input?.[key];
if (key === "logs") {
base[key] = { read: Boolean(raw?.read), write: false };
continue;
}
const write = Boolean(raw?.write);
const read = write || Boolean(raw?.read);
base[key] = { read, write };
}
return base;
}
export type SessionAccount = {
id: string;
username: string;
isAdmin: boolean;
permissions: PagePermissions;
};
export function canReadPage(
account: SessionAccount,
page: PageKey,
): boolean {
if (account.isAdmin) return true;
return account.permissions[page].read;
}
export function canWritePage(
account: SessionAccount,
page: PageKey,
): boolean {
if (account.isAdmin) return true;
if (page === "logs") return false;
return account.permissions[page].write;
}
/** Map dashboard tab (except overview) to a permission page key. */
export function tabToPageKey(
tab: string,
): PageKey | null {
if (
tab === "players" ||
tab === "matches" ||
tab === "analysis" ||
tab === "matchmaker" ||
tab === "ledger" ||
tab === "logs"
) {
return tab;
}
return null;
}
+67
View File
@@ -0,0 +1,67 @@
import { findAccountByUsername } from "@/lib/auth/accounts-store";
import {
canReadPage,
canWritePage,
fullPagePermissions,
normalizePagePermissions,
type PageKey,
type SessionAccount,
} from "@/lib/auth/permissions";
import { parseSessionToken } from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function getSessionAccount(): Promise<SessionAccount | null> {
const cookieStore = await cookies();
const username = parseSessionToken(
cookieStore.get(ADMIN_SESSION_COOKIE)?.value,
);
if (!username) return null;
const record = await findAccountByUsername(username);
if (!record) return null;
return {
id: record.id,
username: record.username,
isAdmin: record.isAdmin,
permissions: record.isAdmin
? fullPagePermissions()
: normalizePagePermissions(record.permissions),
};
}
export async function requireSession(): Promise<SessionAccount> {
const account = await getSessionAccount();
if (!account) {
redirect("/login");
}
return account;
}
export async function requireAdmin(): Promise<SessionAccount> {
const account = await requireSession();
if (!account.isAdmin) {
redirect("/");
}
return account;
}
export async function requirePageRead(
page: PageKey,
): Promise<SessionAccount> {
const account = await requireSession();
if (!canReadPage(account, page)) {
redirect("/");
}
return account;
}
export async function requirePageWrite(
page: PageKey,
): Promise<SessionAccount> {
const account = await requireSession();
if (!canWritePage(account, page)) {
redirect("/");
}
return account;
}
+58
View File
@@ -0,0 +1,58 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/** Legacy cookie values from the old role-based session (invalidate on parse). */
export const LEGACY_ADMIN_SESSION_VALUE = "1";
function getSessionSecret(): string {
const explicit = process.env.ADMIN_SESSION_SECRET?.trim();
if (explicit) return explicit;
const bootstrap = process.env.ADMIN_PASSWORD?.trim();
if (bootstrap) return `kickkings-session:${bootstrap}`;
return "kickkings-dev-session-secret";
}
function signUsername(username: string): string {
return createHmac("sha256", getSessionSecret())
.update(username)
.digest("base64url");
}
/** Build cookie payload: `username.signature`. */
export function createSessionToken(username: string): string {
const u = username.trim();
return `${u}.${signUsername(u)}`;
}
/**
* Parse and verify a signed session cookie.
* Returns the username, or null if invalid / legacy.
*/
export function parseSessionToken(value: string | undefined): string | null {
if (!value) return null;
// Invalidate old role cookies immediately.
if (
value === LEGACY_ADMIN_SESSION_VALUE ||
value === "admin" ||
value === "supervisor"
) {
return null;
}
const dot = value.lastIndexOf(".");
if (dot <= 0 || dot === value.length - 1) return null;
const username = value.slice(0, dot);
const sig = value.slice(dot + 1);
if (!username || !sig) return null;
const expected = signUsername(username);
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length) return null;
if (!timingSafeEqual(a, b)) return null;
return username;
}
/** Whether the cookie looks like a valid signed session (for middleware/proxy). */
export function hasValidSessionToken(value: string | undefined): boolean {
return parseSessionToken(value) != null;
}
+19 -3
View File
@@ -1,5 +1,7 @@
import type { NextResponse } from "next/server";
import { createSessionToken } from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session";
import { getRequestOrigin } from "@/lib/request-public-url";
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
function isHttpsRequest(request: Request): boolean {
@@ -8,11 +10,21 @@ function isHttpsRequest(request: Request): boolean {
} catch {
/* ignore */
}
const xf = request.headers.get("x-forwarded-proto");
const xf = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
if (xf === "https") return true;
if (xf === "http") return false;
const fwd = request.headers.get("forwarded");
if (fwd) {
const m = /(?:^|[;,]\s*)proto=(https?)/i.exec(fwd);
if (m?.[1] === "https") return true;
if (m?.[1] === "http") return false;
}
try {
return getRequestOrigin(request).startsWith("https://");
} catch {
return false;
}
}
export function getSessionCookieSetOptions(request: Request) {
return {
@@ -35,10 +47,14 @@ export function getSessionCookieClearOptions(request: Request) {
}
/** Attach session cookie to a response (works reliably with redirects). */
export function applySessionCookie(res: NextResponse, request: Request) {
export function applySessionCookie(
res: NextResponse,
request: Request,
username: string,
) {
res.cookies.set(
ADMIN_SESSION_COOKIE,
"1",
createSessionToken(username),
getSessionCookieSetOptions(request),
);
}
+22
View File
@@ -70,3 +70,25 @@ export function formatRcBalanceWithCoins(rc: number | null): string {
}
return `${rc.toFixed(1)} RC`;
}
/** Maps total coin units to `users.rc` display (whole + tenths/10). */
export function coinsBigIntToRcNumber(coins: bigint): number {
let c = coins;
if (c < BigInt(0)) c = BigInt(0);
const whole = c / BigInt(4);
const tenths = c % BigInt(4);
return Number(whole) + Number(tenths) / 10;
}
/** Adds coin units to an existing RC balance (same encoding as `users.rc`). */
export function applyCoinsDeltaToRcBalance(
currentRc: number | null,
deltaCoins: bigint,
): number {
const existingCoins =
currentRc != null && Number.isFinite(currentRc)
? rcToCoins(currentRc) ?? 0
: 0;
const total = BigInt(existingCoins) + deltaCoins;
return coinsBigIntToRcNumber(total);
}
+32 -1
View File
@@ -6,7 +6,9 @@ export type AdminDashboardTab =
| "players"
| "matches"
| "matchmaker"
| "ledger";
| "ledger"
| "analysis"
| "logs";
export type DashboardUrlQuery = {
tab: AdminDashboardTab;
@@ -14,6 +16,8 @@ export type DashboardUrlQuery = {
participantRaw: string | null;
editId?: number | null;
saveError?: boolean;
/** Ledger: failed system supply action */
supplyError?: boolean;
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
matchmakerSource?: MatchmakerLogSource | null;
/** Ledger tab: UTC date-only bounds (`lfrom` / `lto` query params). */
@@ -24,6 +28,15 @@ export type DashboardUrlQuery = {
ledgerPageSize?: number | null;
ledgerSort?: string | null;
ledgerOrder?: "asc" | "desc" | null;
/** Analysis tab: UTC date-only bounds (`afrom` / `ato`). */
analysisFrom?: string | null;
analysisTo?: string | null;
/** Analysis tab: comma-separated player ids (`aplayers`). */
analysisPlayers?: string | null;
/** Matches tab: local date-only bounds (`mfrom` / `mto`) + tz offset minutes (`mtz`). */
matchesFrom?: string | null;
matchesTo?: string | null;
matchesTzOffsetMinutes?: number | null;
};
/** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */
@@ -33,6 +46,8 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
else if (q.tab === "players") p.set("tab", "players");
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
else if (q.tab === "ledger") p.set("tab", "ledger");
else if (q.tab === "analysis") p.set("tab", "analysis");
else if (q.tab === "logs") p.set("tab", "logs");
if (q.highlightId) p.set("highlight", q.highlightId);
if (q.participantRaw) p.set("participant", q.participantRaw);
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
@@ -58,10 +73,26 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
p.set("lsize", String(Math.trunc(q.ledgerPageSize)));
}
}
if (q.tab === "analysis") {
if (q.analysisFrom) p.set("afrom", q.analysisFrom);
if (q.analysisTo) p.set("ato", q.analysisTo);
if (q.analysisPlayers) p.set("aplayers", q.analysisPlayers);
}
if (q.tab === "matches") {
if (q.matchesFrom) p.set("mfrom", q.matchesFrom);
if (q.matchesTo) p.set("mto", q.matchesTo);
if (
q.matchesTzOffsetMinutes != null &&
Number.isFinite(q.matchesTzOffsetMinutes)
) {
p.set("mtz", String(Math.trunc(q.matchesTzOffsetMinutes)));
}
}
if (q.editId != null && Number.isFinite(q.editId)) {
p.set("edit", String(q.editId));
}
if (q.saveError) p.set("saveError", "1");
if (q.supplyError) p.set("supplyErr", "1");
const s = p.toString();
return s ? `/?${s}` : "/";
}
+5 -1
View File
@@ -14,6 +14,7 @@ export type DashboardStatsSnapshot = {
export type LeaderboardRow = {
userId: number;
username: string | null;
email: string | null;
/** Matches with `winner_id` equal to this user (same as player card). */
matchesWon: number;
rcBalance: number | null;
@@ -105,7 +106,7 @@ async function fetchTopPlayersByMatchWins(
);
const [usersRes, ...playedRes] = await Promise.all([
supabase.from("users").select("id, username, rc").in("id", ids),
supabase.from("users").select("id, username, email, rc").in("id", ids),
...playedPromises,
]);
@@ -114,10 +115,12 @@ async function fetchTopPlayersByMatchWins(
}
const nameById = new Map<number, string | null>();
const emailById = new Map<number, string | null>();
const rcById = new Map<number, number | null>();
for (const u of usersRes.data ?? []) {
const uid = u.id as number;
nameById.set(uid, (u.username as string | null) ?? null);
emailById.set(uid, (u.email as string | null) ?? null);
const rawRc = u.rc as number | null;
rcById.set(
uid,
@@ -146,6 +149,7 @@ async function fetchTopPlayersByMatchWins(
return {
userId,
username: nameById.get(userId) ?? null,
email: emailById.get(userId) ?? null,
matchesWon,
rcBalance: rcById.get(userId) ?? null,
winRatePercent,
+178
View File
@@ -0,0 +1,178 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { countryCodeFromIp } from "@/lib/ip-geolocation";
import type { DbUser } from "@/types/database";
export type GeolocationCountryStat = {
countryCode: string;
count: number;
avgPing: number | null;
};
export type GeolocationAnalyticsResult = {
userAccounts: GeolocationCountryStat[];
matches: GeolocationCountryStat[];
error: string | null;
};
const EMPTY_RESULT: GeolocationAnalyticsResult = {
userAccounts: [],
matches: [],
error: null,
};
async function resolveUniqueIps(
ips: Iterable<string | null | undefined>,
): Promise<Map<string, string | null>> {
const unique = new Set<string>();
for (const ip of ips) {
const trimmed = (ip ?? "").trim();
if (trimmed) unique.add(trimmed);
}
const entries = await Promise.all(
[...unique].map(async (ip) => [ip, await countryCodeFromIp(ip)] as const),
);
return new Map(entries);
}
function isValidCountryCode(code: string | null | undefined): code is string {
const cc = (code ?? "").trim().toUpperCase();
return /^[A-Z]{2}$/.test(cc);
}
export async function loadUserGeolocationStats(
users: DbUser[],
playerIds: number[],
): Promise<GeolocationCountryStat[]> {
let filtered = users;
if (playerIds.length > 0) {
const allowed = new Set(playerIds);
filtered = users.filter((u) => allowed.has(u.id));
}
const ips = filtered.map((u) => u.ip_address);
const countryByIp = await resolveUniqueIps(ips);
const byCountry = new Map<string, number>();
for (const user of filtered) {
const ip = user.ip_address?.trim();
if (!ip) continue;
const country = countryByIp.get(ip);
if (!isValidCountryCode(country)) continue;
byCountry.set(country, (byCountry.get(country) ?? 0) + 1);
}
return [...byCountry.entries()]
.map(([countryCode, count]) => ({ countryCode, count, avgPing: null }))
.sort((a, b) => b.count - a.count || a.countryCode.localeCompare(b.countryCode));
}
type PingReportRow = {
match_id: number;
ip_address: string | null;
ping: number;
};
export async function loadMatchGeolocationStats(
supabase: SupabaseClient,
from: string,
to: string,
playerIds: number[],
): Promise<{ stats: GeolocationCountryStat[]; error: string | null }> {
const rangeStartIso = `${from}T00:00:00.000Z`;
const rangeEndIso = `${to}T23:59:59.999Z`;
let query = supabase
.from("ping_reports")
.select("match_id, ip_address, ping")
.gte("created_at", rangeStartIso)
.lte("created_at", rangeEndIso)
.limit(10000);
if (playerIds.length > 0) {
query = query.in("user_id", playerIds);
}
const { data, error } = await query;
if (error) {
return { stats: [], error: error.message };
}
const rows = (data ?? []) as PingReportRow[];
const countryByIp = await resolveUniqueIps(rows.map((r) => r.ip_address));
const byCountry = new Map<
string,
{ matchIds: Set<number>; pings: number[] }
>();
for (const row of rows) {
const ip = row.ip_address?.trim();
if (!ip) continue;
const country = countryByIp.get(ip);
if (!isValidCountryCode(country)) continue;
const matchId = Number(row.match_id);
const ping = Number(row.ping);
if (!Number.isFinite(matchId) || !Number.isFinite(ping)) continue;
const state = byCountry.get(country) ?? {
matchIds: new Set<number>(),
pings: [],
};
state.matchIds.add(matchId);
state.pings.push(ping);
byCountry.set(country, state);
}
const stats = [...byCountry.entries()]
.map(([countryCode, state]) => {
const pings = state.pings;
const avgPing =
pings.length > 0
? pings.reduce((acc, value) => acc + value, 0) / pings.length
: null;
return {
countryCode,
count: state.matchIds.size,
avgPing,
};
})
.sort((a, b) => b.count - a.count || a.countryCode.localeCompare(b.countryCode));
return { stats, error: null };
}
export async function loadGeolocationAnalytics(
supabase: SupabaseClient,
users: DbUser[],
from: string,
to: string,
playerIds: number[],
): Promise<GeolocationAnalyticsResult> {
try {
const [userAccounts, matchResult] = await Promise.all([
loadUserGeolocationStats(users, playerIds),
loadMatchGeolocationStats(supabase, from, to, playerIds),
]);
if (matchResult.error) {
return {
userAccounts,
matches: [],
error: matchResult.error,
};
}
return {
userAccounts,
matches: matchResult.stats,
error: null,
};
} catch (err) {
return {
...EMPTY_RESULT,
error: err instanceof Error ? err.message : "Failed to load geolocation data",
};
}
}
+40
View File
@@ -0,0 +1,40 @@
/** Blue (low) → green (mid) → red (high) heatmap fill for ratio in [0, 1]. */
export function heatmapColor(ratio: number): string {
const t = Math.max(0, Math.min(1, ratio));
let hue: number;
if (t <= 0.5) {
hue = 220 + (120 - 220) * (t / 0.5);
} else {
hue = 120 + (0 - 120) * ((t - 0.5) / 0.5);
}
return `hsl(${hue.toFixed(1)}, 68%, 42%)`;
}
export function heatmapColorForCount(
count: number,
minCount: number,
maxCount: number,
): string {
if (count <= 0 || maxCount <= 0) {
return NO_DATA_FILL;
}
if (maxCount === minCount) {
return heatmapColor(0.5);
}
const ratio = (count - minCount) / (maxCount - minCount);
return heatmapColor(ratio);
}
export const NO_DATA_FILL = "#d4d4d8";
export const NO_DATA_FILL_DARK = "#3f3f46";
export function countryDisplayName(countryCode: string): string {
try {
const name = new Intl.DisplayNames(["en"], { type: "region" }).of(
countryCode,
);
return name ?? countryCode;
} catch {
return countryCode;
}
}
+87
View File
@@ -0,0 +1,87 @@
const IPV4_WITH_OPTIONAL_PORT = /^(\d{1,3}(?:\.\d{1,3}){3})(?::\d{1,5})?$/;
const COUNTRY_LOOKUP_ENDPOINT = "https://api.ipgeolocation.io/v3/ipgeo";
const geoLookupCache = new Map<string, Promise<string | null>>();
function normalizeIp(raw: string | null | undefined): string | null {
const value = (raw ?? "").trim();
if (!value) return null;
if (value.toLowerCase() === "unknown") return null;
const first = value.split(",")[0]?.trim() ?? value;
if (!first) return null;
if (first.startsWith("::ffff:")) {
return first.slice("::ffff:".length);
}
const v4WithPort = IPV4_WITH_OPTIONAL_PORT.exec(first);
if (v4WithPort) {
return v4WithPort[1] ?? null;
}
// Handle [ipv6]:port style values.
if (first.startsWith("[") && first.includes("]")) {
const end = first.indexOf("]");
if (end > 1) return first.slice(1, end);
}
return first;
}
async function fetchCountryCodeFromApi(ip: string): Promise<string | null> {
const apiKey = process.env.IPGEOLOCATION_API_KEY?.trim();
if (!apiKey) return null;
const url = new URL(COUNTRY_LOOKUP_ENDPOINT);
url.searchParams.set("apiKey", apiKey);
url.searchParams.set("ip", ip);
try {
const res = await fetch(url.toString(), {
method: "GET",
headers: { Accept: "application/json" },
// 12h cache for same IP in Next server runtime.
next: { revalidate: 60 * 60 * 12 },
});
if (!res.ok) return null;
const json = (await res.json()) as {
location?: { country_code2?: string | null };
};
const cc = json.location?.country_code2?.trim().toUpperCase();
return cc ? cc : null;
} catch {
return null;
}
}
/** Best-effort ISO country code from an IP address (e.g. "IN", "US"). */
export async function countryCodeFromIp(
ip: string | null | undefined,
): Promise<string | null> {
const normalized = normalizeIp(ip);
if (!normalized) return null;
const cached = geoLookupCache.get(normalized);
if (cached) return cached;
const promise = fetchCountryCodeFromApi(normalized);
geoLookupCache.set(normalized, promise);
const result = await promise;
if (result == null) {
// allow retries for transient API/network failures
geoLookupCache.delete(normalized);
}
return result;
}
/** Convert ISO country code (e.g. "IN") to emoji flag (e.g. 🇮🇳). */
export function countryFlagFromCode(
countryCode: string | null | undefined,
): string | null {
const cc = (countryCode ?? "").trim().toUpperCase();
if (!/^[A-Z]{2}$/.test(cc)) return null;
const base = 127397; // Regional indicator symbol letter A offset.
const chars = [...cc].map((ch) =>
String.fromCodePoint(base + ch.charCodeAt(0)),
);
return chars.join("");
}
+101
View File
@@ -0,0 +1,101 @@
import {
parseIsoDateOnly,
utcLast30DaysDateRange,
} from "@/lib/ledger-utc-date-range";
/** Rolling last 30 local calendar days (browser / given tz offset). */
export function localLast30DaysDateRange(
tzOffsetMinutes: number,
): { from: string; to: string } {
const now = new Date();
// Instant that has the same Y-M-D components in UTC as "now" in the local zone.
const localNowMs = now.getTime() - tzOffsetMinutes * 60_000;
const localNow = new Date(localNowMs);
const y = localNow.getUTCFullYear();
const m = localNow.getUTCMonth();
const d = localNow.getUTCDate();
const to = new Date(Date.UTC(y, m, d));
const from = new Date(to);
from.setUTCDate(from.getUTCDate() - 29);
return {
from: from.toISOString().slice(0, 10),
to: to.toISOString().slice(0, 10),
};
}
/**
* Parse `getTimezoneOffset()`-style minutes from a query param.
* Returns null when absent or invalid.
*/
export function parseTzOffsetMinutes(raw: string | null): number | null {
if (raw == null || raw.trim() === "") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return null;
const trunc = Math.trunc(n);
// Valid JS offsets are roughly ±14h; allow a little slack.
if (trunc < -16 * 60 || trunc > 16 * 60) return null;
return trunc;
}
/**
* Inclusive local calendar-day bounds UTC ISO instants for DB filtering.
* `tzOffsetMinutes` is `Date#getTimezoneOffset()` (minutes to add to local to get UTC).
*/
export function localDateRangeToUtcIsoBounds(
from: string,
to: string,
tzOffsetMinutes: number,
): { rangeStartIso: string; rangeEndIso: string } {
const [fy, fm, fd] = from.split("-").map(Number) as [number, number, number];
const [ty, tm, td] = to.split("-").map(Number) as [number, number, number];
const startUtcMs =
Date.UTC(fy, fm - 1, fd, 0, 0, 0, 0) + tzOffsetMinutes * 60_000;
const endUtcMs =
Date.UTC(ty, tm - 1, td, 23, 59, 59, 999) + tzOffsetMinutes * 60_000;
return {
rangeStartIso: new Date(startUtcMs).toISOString(),
rangeEndIso: new Date(endUtcMs).toISOString(),
};
}
/** Normalize match date bounds; uses local last-30 when tz is known, else UTC last-30. */
export function normalizeMatchesDateRange(
fromRaw: string | null,
toRaw: string | null,
tzOffsetMinutes: number | null,
): { from: string; to: string } {
const fallback =
tzOffsetMinutes != null
? localLast30DaysDateRange(tzOffsetMinutes)
: utcLast30DaysDateRange();
let from = (fromRaw ?? "").trim();
let to = (toRaw ?? "").trim();
if (!parseIsoDateOnly(from) || !parseIsoDateOnly(to)) {
return fallback;
}
if (from > to) {
const t = from;
from = to;
to = t;
}
return { from, to };
}
/** Format an ISO timestamp in the viewer's local timezone (client-only). */
export function formatLocalTimestamp(value: string | null): string {
if (!value) return "—";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, "0");
const y = d.getFullYear();
const m = pad(d.getMonth() + 1);
const day = pad(d.getDate());
const h = pad(d.getHours());
const min = pad(d.getMinutes());
const s = pad(d.getSeconds());
return `${y}-${m}-${day} ${h}:${min}:${s}`;
} catch {
return value;
}
}
+19
View File
@@ -0,0 +1,19 @@
/** Game emoji emote id → Unicode glyph (admin analysis labels). */
const EMOJI_GLYPH_BY_ID: Record<number, string> = {
0: "😂", // laughing crying
1: "😢", // crying
2: "💀", // dead skull
3: "😈", // evil
4: "🤬", // angry swearing
5: "🤑", // money face
};
/** Chart / aggregate label: glyph plus id in brackets, e.g. `😂 [0]`. */
export function formatEmojiEmoteLabel(id: string): string {
const n = Number(id);
const glyph =
Number.isInteger(n) && Object.hasOwn(EMOJI_GLYPH_BY_ID, n)
? EMOJI_GLYPH_BY_ID[n]!
: "?";
return `${glyph} [${id}]`;
}
+73 -29
View File
@@ -6,36 +6,78 @@ function normRemark(r: string | null | undefined): string {
return (r ?? "").toLowerCase().trim();
}
/**
* For each match_id, average `entry_hold` debit amount (same per player when
* both rows exist; sum/count handles odd counts).
*/
export async function fetchEntryHoldPerPlayerCoinsByMatchIds(
supabase: SupabaseClient,
matchIds: number[],
): Promise<Map<number, bigint>> {
type PerRemarkAgg = {
sums: Map<number, bigint>;
counts: Map<number, number>;
};
function bumpAgg(agg: PerRemarkAgg, matchId: number, amount: bigint): void {
agg.sums.set(matchId, (agg.sums.get(matchId) ?? BigInt(0)) + amount);
agg.counts.set(matchId, (agg.counts.get(matchId) ?? 0) + 1);
}
function avgPerMatch(agg: PerRemarkAgg): Map<number, bigint> {
const out = new Map<number, bigint>();
if (matchIds.length === 0) return out;
const { data, error } = await supabase
.from("transactions")
.select("match_id, amount, remarks")
.in("match_id", matchIds);
if (error) {
for (const [mid, sum] of agg.sums) {
const c = agg.counts.get(mid) ?? 0;
if (c > 0) out.set(mid, sum / BigInt(c));
}
return out;
}
const sums = new Map<number, bigint>();
const counts = new Map<number, number>();
export type MatchEntryHoldAndFeePerPlayerCoins = {
holdPerPlayer: Map<number, bigint>;
feePerPlayer: Map<number, bigint>;
};
for (const row of (data ?? []) as Pick<
/** PostgREST / Supabase default max rows per request. */
const TX_PAGE_SIZE = 1000;
/**
* For each match_id, average debit amount for `entry_hold` and `entry_fee`
* (per player when two rows exist; sum/count handles odd counts).
*/
export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
supabase: SupabaseClient,
matchIds: number[],
): Promise<MatchEntryHoldAndFeePerPlayerCoins> {
const empty: MatchEntryHoldAndFeePerPlayerCoins = {
holdPerPlayer: new Map(),
feePerPlayer: new Map(),
};
if (matchIds.length === 0) return empty;
const holdAgg: PerRemarkAgg = {
sums: new Map(),
counts: new Map(),
};
const feeAgg: PerRemarkAgg = {
sums: new Map(),
counts: new Map(),
};
let offset = 0;
for (;;) {
const { data, error } = await supabase
.from("transactions")
.select("match_id, amount, remarks")
.in("match_id", matchIds)
.in("remarks", ["entry_hold", "entry_fee"])
.order("id", { ascending: true })
.range(offset, offset + TX_PAGE_SIZE - 1);
if (error) {
return empty;
}
const batch = (data ?? []) as Pick<
DbTransaction,
"match_id" | "amount" | "remarks"
>[]) {
if (normRemark(row.remarks) !== "entry_hold") {
continue;
}
>[];
for (const row of batch) {
const rmk = normRemark(row.remarks);
if (rmk !== "entry_hold" && rmk !== "entry_fee") continue;
const midRaw = row.match_id;
if (midRaw == null) continue;
const mid =
@@ -43,14 +85,16 @@ export async function fetchEntryHoldPerPlayerCoinsByMatchIds(
if (!Number.isFinite(mid)) continue;
const a = txAmountToBigInt(row.amount);
if (a === BigInt(0)) continue;
sums.set(mid, (sums.get(mid) ?? BigInt(0)) + a);
counts.set(mid, (counts.get(mid) ?? 0) + 1);
if (rmk === "entry_hold") bumpAgg(holdAgg, mid, a);
else bumpAgg(feeAgg, mid, a);
}
for (const [mid, sum] of sums) {
const c = counts.get(mid) ?? 0;
if (c > 0) out.set(mid, sum / BigInt(c));
if (batch.length < TX_PAGE_SIZE) break;
offset += TX_PAGE_SIZE;
}
return out;
return {
holdPerPlayer: avgPerMatch(holdAgg),
feePerPlayer: avgPerMatch(feeAgg),
};
}
+213
View File
@@ -0,0 +1,213 @@
import type { DbMatch } from "@/types/database";
import { readMatchLogFile } from "@/lib/match-logs-server";
import {
aggregateNumeric,
emptyMatchLogAnalysisResult,
matchLogHasMirrorExceptionDisconnect,
matchLogHasWinnerPatch,
mergeEmoteCounts,
parseMatchLogContent,
type MatchLogAnalysisResult,
} from "@/lib/match-log-parser";
function coalesceUserId(v: number | string | null): number | null {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? Math.trunc(n) : null;
}
export function matchInvolvesAnyPlayer(
m: DbMatch,
playerIds: number[],
): boolean {
if (playerIds.length === 0) return true;
const red = coalesceUserId(m.user_red);
const blue = coalesceUserId(m.user_blue);
for (const id of playerIds) {
if (red === id || blue === id) return true;
}
return false;
}
export function matchInUtcDateRange(
m: DbMatch,
from: string,
to: string,
): boolean {
const created = m.created_at;
if (!created) return false;
const day = created.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
try {
const d = new Date(created);
if (Number.isNaN(d.getTime())) return false;
const isoDay = d.toISOString().slice(0, 10);
return isoDay >= from && isoDay <= to;
} catch {
return false;
}
}
return day >= from && day <= to;
}
/** Parse `aplayers` query param(s): repeated keys and/or comma-separated ids. */
export function parseAnalysisPlayerIds(
raw: string | string[] | null | undefined,
): number[] {
const parts: string[] = [];
if (raw == null) return [];
if (Array.isArray(raw)) {
for (const item of raw) {
if (typeof item === "string" && item.trim()) parts.push(item);
}
} else if (typeof raw === "string" && raw.trim()) {
parts.push(raw);
}
const out: number[] = [];
for (const chunk of parts) {
for (const segment of chunk.split(",")) {
const t = segment.trim();
if (!t) continue;
const n = Number(t);
if (Number.isInteger(n) && n >= 1) out.push(n);
}
}
return [...new Set(out)].sort((a, b) => a - b);
}
const MAX_MATCHES_TO_SCAN = 500;
/**
* Read and aggregate match logs for matches in the given filter.
* Uses filesystem logs via MATCH_LOGS_DIR.
*/
export async function analyzeMatchLogsForMatches(
matches: DbMatch[],
from: string,
to: string,
playerIds: number[],
): Promise<MatchLogAnalysisResult> {
const filtered = matches.filter(
(m) =>
matchInUtcDateRange(m, from, to) &&
matchInvolvesAnyPlayer(m, playerIds),
);
const toScan = filtered.slice(0, MAX_MATCHES_TO_SCAN);
const dirConfigured = Boolean(process.env.MATCH_LOGS_DIR?.trim());
if (!dirConfigured) {
return {
...emptyMatchLogAnalysisResult(
"Match logs directory is not configured (set MATCH_LOGS_DIR on the server).",
),
matchesInFilter: filtered.length,
};
}
const durations: number[] = [];
const shotCounts: number[] = [];
const forceMagnitudes: number[] = [];
const forcePoints: { x: number; y: number }[] = [];
const textMaps: Record<string, number>[] = [];
const emojiMaps: Record<string, number>[] = [];
const scanResults = await Promise.all(
toScan.map(async (m) => {
const id = Number(m.id);
if (!Number.isFinite(id)) {
return {
kind: "invalid" as const,
};
}
const res = await readMatchLogFile(id);
if (!res.ok) {
return {
kind: "missing" as const,
status: res.status,
};
}
const mirrorExceptionDisconnect = matchLogHasMirrorExceptionDisconnect(
res.content,
);
const winnerPatchLogged = matchLogHasWinnerPatch(res.content);
const parsed = parseMatchLogContent(res.content);
if (!parsed) {
return {
kind: "unparsed" as const,
matchId: id,
mirrorExceptionDisconnect,
winnerPatchLogged,
};
}
return {
kind: "ok" as const,
matchId: id,
parsed,
mirrorExceptionDisconnect,
winnerPatchLogged,
};
}),
);
let matchesWithLogs = 0;
let matchesMissingLogs = 0;
let matchesSkippedTooLarge = 0;
let matchesWithLogFiles = 0;
let matchesWithWinnerPatch = 0;
const mirrorExceptionDisconnectMatchIds: number[] = [];
const notEndedMatchIds: number[] = [];
for (const row of scanResults) {
if (row.kind === "invalid") continue;
if (row.kind === "missing") {
if (row.status === 413) matchesSkippedTooLarge += 1;
else matchesMissingLogs += 1;
continue;
}
matchesWithLogFiles += 1;
if (row.winnerPatchLogged) {
matchesWithWinnerPatch += 1;
} else {
notEndedMatchIds.push(row.matchId);
}
if (row.mirrorExceptionDisconnect) {
mirrorExceptionDisconnectMatchIds.push(row.matchId);
}
if (row.kind === "unparsed") {
matchesMissingLogs += 1;
continue;
}
matchesWithLogs += 1;
if (row.winnerPatchLogged) {
durations.push(row.parsed.durationSeconds);
shotCounts.push(row.parsed.shotCount);
}
forceMagnitudes.push(...row.parsed.forceMagnitudes);
forcePoints.push(...row.parsed.forceVectors);
textMaps.push(row.parsed.textEmotes);
emojiMaps.push(row.parsed.emojiEmotes);
}
mirrorExceptionDisconnectMatchIds.sort((a, b) => a - b);
notEndedMatchIds.sort((a, b) => a - b);
return {
matchesInFilter: filtered.length,
matchesScanned: toScan.length,
matchesWithLogs,
matchesMissingLogs,
matchesSkippedTooLarge,
duration: aggregateNumeric(durations),
shots: aggregateNumeric(shotCounts),
force: aggregateNumeric(forceMagnitudes),
forcePoints,
textEmotes: mergeEmoteCounts(textMaps),
emojiEmotes: mergeEmoteCounts(emojiMaps),
mirrorExceptionDisconnectMatchIds,
matchesWithLogFiles,
matchesWithWinnerPatch,
notEndedMatchIds,
configError: null,
};
}
+250
View File
@@ -0,0 +1,250 @@
import { formatEmojiEmoteLabel } from "@/lib/match-emoji-labels";
/** Parsed metrics from a single match log file. */
export type ParsedMatchLog = {
durationSeconds: number;
shotCount: number;
forceMagnitudes: number[];
forceVectors: { x: number; y: number }[];
textEmotes: Record<string, number>;
emojiEmotes: Record<string, number>;
};
const LINE_TS =
/^\[(\d{1,2})\/(\d{1,2})\/(\d{4}) (\d{1,2}):(\d{2}):(\d{2})(?:\s*([AP]M))?\]\s*(.*)$/i;
const LAUNCH_PUCK =
/launching puck at \(([-\d.]+),\s*([-\d.]+)\) with \(([-\d.]+),\s*([-\d.]+)\) force/;
const TEXT_EMOTE = /Client \d+ sent text emote (.+)$/;
const EMOJI_EMOTE = /Client \d+ sent emoji emote (\d+)$/;
/** Mirror disconnect after a command handler threw (see `[Mirror/Error]` log lines). */
const MIRROR_EXCEPTION_DISCONNECT =
/\[Mirror\/Error\][^\n]*Disconnecting connection[^\n]*caused an Exception/i;
/** True when the log records a player disconnect caused by a Mirror command exception. */
export function matchLogHasMirrorExceptionDisconnect(content: string): boolean {
return MIRROR_EXCEPTION_DISCONNECT.test(content);
}
/** Log line written when the dedicated server records the match winner. */
const WINNER_PATCH_SUCCESS = /Dedicated match winner PATCH success/;
/** True when the match log includes the post-win winner PATCH line. */
export function matchLogHasWinnerPatch(content: string): boolean {
return WINNER_PATCH_SUCCESS.test(content);
}
function parseLogTimestamp(
month: string,
day: string,
year: string,
hour: string,
minute: string,
second: string,
ampm?: string,
): number | null {
const m = Number(month);
const d = Number(day);
const y = Number(year);
let h = Number(hour);
const min = Number(minute);
const s = Number(second);
if (ampm) {
const isPm = ampm.toUpperCase() === "PM";
if (h === 12) h = isPm ? 12 : 0;
else if (isPm) h += 12;
}
if (
!Number.isFinite(m) ||
!Number.isFinite(d) ||
!Number.isFinite(y) ||
!Number.isFinite(h) ||
!Number.isFinite(min) ||
!Number.isFinite(s)
) {
return null;
}
const ms = Date.UTC(y, m - 1, d, h, min, s);
const dt = new Date(ms);
if (
dt.getUTCFullYear() !== y ||
dt.getUTCMonth() !== m - 1 ||
dt.getUTCDate() !== d
) {
return null;
}
return ms;
}
function forceMagnitude(fx: number, fy: number): number {
return Math.hypot(fx, fy);
}
/** Parse match log text into per-match gameplay metrics. */
export function parseMatchLogContent(content: string): ParsedMatchLog | null {
const lines = content.split(/\r?\n/);
let firstMs: number | null = null;
let lastMs: number | null = null;
const forceMagnitudes: number[] = [];
const forceVectors: { x: number; y: number }[] = [];
const textEmotes: Record<string, number> = {};
const emojiEmotes: Record<string, number> = {};
let shotCount = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const tsMatch = LINE_TS.exec(trimmed);
if (tsMatch) {
const ms = parseLogTimestamp(
tsMatch[1]!,
tsMatch[2]!,
tsMatch[3]!,
tsMatch[4]!,
tsMatch[5]!,
tsMatch[6]!,
tsMatch[7],
);
if (ms != null) {
if (firstMs == null) firstMs = ms;
lastMs = ms;
}
const body = tsMatch[8] ?? "";
const launch = LAUNCH_PUCK.exec(body);
if (launch) {
shotCount += 1;
const fx = Number(launch[3]);
const fy = Number(launch[4]);
if (Number.isFinite(fx) && Number.isFinite(fy)) {
forceVectors.push({ x: fx, y: fy });
forceMagnitudes.push(forceMagnitude(fx, fy));
}
} else {
const text = TEXT_EMOTE.exec(body);
if (text) {
const label = text[1]!.trim();
if (label) textEmotes[label] = (textEmotes[label] ?? 0) + 1;
} else {
const emoji = EMOJI_EMOTE.exec(body);
if (emoji) {
const id = emoji[1]!;
const key = formatEmojiEmoteLabel(id);
emojiEmotes[key] = (emojiEmotes[key] ?? 0) + 1;
}
}
}
continue;
}
const launchBare = LAUNCH_PUCK.exec(trimmed);
if (launchBare) {
shotCount += 1;
const fx = Number(launchBare[3]);
const fy = Number(launchBare[4]);
if (Number.isFinite(fx) && Number.isFinite(fy)) {
forceVectors.push({ x: fx, y: fy });
forceMagnitudes.push(forceMagnitude(fx, fy));
}
}
}
if (firstMs == null || lastMs == null) return null;
const durationSeconds = Math.max(0, (lastMs - firstMs) / 1000);
return {
durationSeconds,
shotCount,
forceMagnitudes,
forceVectors,
textEmotes,
emojiEmotes,
};
}
export type NumericAggregate = {
avg: number;
min: number;
max: number;
};
export function aggregateNumeric(values: number[]): NumericAggregate | null {
if (values.length === 0) return null;
let min = values[0]!;
let max = values[0]!;
let sum = 0;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
sum += v;
}
return { avg: sum / values.length, min, max };
}
export type EmoteBarRow = { label: string; count: number };
export function mergeEmoteCounts(
maps: Record<string, number>[],
): EmoteBarRow[] {
const merged: Record<string, number> = {};
for (const map of maps) {
for (const [label, count] of Object.entries(map)) {
merged[label] = (merged[label] ?? 0) + count;
}
}
return Object.entries(merged)
.map(([label, count]) => ({ label, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
}
/** JSON-serializable analysis bundle for the admin UI. */
export type MatchLogAnalysisResult = {
matchesInFilter: number;
/** Matches actually read (capped for performance). */
matchesScanned: number;
matchesWithLogs: number;
matchesMissingLogs: number;
matchesSkippedTooLarge: number;
duration: NumericAggregate | null;
shots: NumericAggregate | null;
force: NumericAggregate | null;
forcePoints: { x: number; y: number }[];
textEmotes: EmoteBarRow[];
emojiEmotes: EmoteBarRow[];
/** Match ids (in filter range, among scanned logs) with a Mirror exception disconnect. */
mirrorExceptionDisconnectMatchIds: number[];
/** Matches with a log file read (parsed or not). */
matchesWithLogFiles: number;
/** Subset of `matchesWithLogFiles` that include the winner PATCH line. */
matchesWithWinnerPatch: number;
/** Log read but no winner PATCH — match did not end normally. */
notEndedMatchIds: number[];
configError: string | null;
};
export function emptyMatchLogAnalysisResult(
configError: string | null = null,
): MatchLogAnalysisResult {
return {
matchesInFilter: 0,
matchesScanned: 0,
matchesWithLogs: 0,
matchesMissingLogs: 0,
matchesSkippedTooLarge: 0,
duration: null,
shots: null,
force: null,
forcePoints: [],
textEmotes: [],
emojiEmotes: [],
mirrorExceptionDisconnectMatchIds: [],
matchesWithLogFiles: 0,
matchesWithWinnerPatch: 0,
notEndedMatchIds: [],
configError,
};
}
+223
View File
@@ -0,0 +1,223 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { countryCodeFromIp, countryFlagFromCode } from "@/lib/ip-geolocation";
import type { DbPingReport } from "@/types/database";
const BAD_PING_THRESHOLD_MS = 200;
const TOP_ROWS_LIMIT = 20;
type PingReportWithUser = DbPingReport & {
user:
| { id: number; username: string | null; ip_address: string | null }[]
| null;
};
export type PingGroupRow = {
key: string;
label: string;
reports: number;
players: number;
avgPing: number;
minPing: number;
maxPing: number;
badReports: number;
badRatePercent: number;
};
export type PingAnalyticsSummary = {
totalReports: number;
totalMatches: number;
totalPlayers: number;
avgPing: number | null;
p95Ping: number | null;
badThresholdMs: number;
badReports: number;
badRatePercent: number | null;
};
export type PingAnalyticsResult = {
summary: PingAnalyticsSummary;
byMatches: PingGroupRow[];
byPlayers: PingGroupRow[];
byCountries: PingGroupRow[];
error: string | null;
};
function percentile(sorted: number[], ratio: number): number | null {
if (sorted.length === 0) return null;
const idx = Math.min(
sorted.length - 1,
Math.max(0, Math.ceil(sorted.length * ratio) - 1),
);
return sorted[idx] ?? null;
}
function toGroupRow(
key: string,
label: string,
pings: number[],
players: Set<number>,
): PingGroupRow {
const reports = pings.length;
const sum = pings.reduce((acc, value) => acc + value, 0);
const minPing = reports > 0 ? Math.min(...pings) : 0;
const maxPing = reports > 0 ? Math.max(...pings) : 0;
const badReports = pings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length;
const badRatePercent = reports > 0 ? (badReports / reports) * 100 : 0;
return {
key,
label,
reports,
players: players.size,
avgPing: reports > 0 ? sum / reports : 0,
minPing,
maxPing,
badReports,
badRatePercent,
};
}
export async function loadPingAnalytics(
supabase: SupabaseClient,
from: string,
to: string,
playerIds: number[],
): Promise<PingAnalyticsResult> {
const rangeStartIso = `${from}T00:00:00.000Z`;
const rangeEndIso = `${to}T23:59:59.999Z`;
let query = supabase
.from("ping_reports")
.select("id, created_at, user_id, match_id, ip_address, ping, user:users(id, username, ip_address)")
.gte("created_at", rangeStartIso)
.lte("created_at", rangeEndIso)
.order("created_at", { ascending: false })
.limit(10000);
if (playerIds.length > 0) {
query = query.in("user_id", playerIds);
}
const { data, error } = await query;
if (error) {
return {
summary: {
totalReports: 0,
totalMatches: 0,
totalPlayers: 0,
avgPing: null,
p95Ping: null,
badThresholdMs: BAD_PING_THRESHOLD_MS,
badReports: 0,
badRatePercent: null,
},
byMatches: [],
byPlayers: [],
byCountries: [],
error: error.message,
};
}
const rows = (data ?? []) as PingReportWithUser[];
const allPings: number[] = [];
const uniquePlayers = new Set<number>();
const uniqueMatches = new Set<number>();
const byMatch = new Map<string, { label: string; pings: number[]; players: Set<number> }>();
const byPlayer = new Map<string, { label: string; pings: number[]; players: Set<number> }>();
const byCountry = new Map<string, { label: string; pings: number[]; players: Set<number> }>();
const ipByRowIndex = new Map<number, string | null>();
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const user = row.user?.[0] ?? null;
ipByRowIndex.set(i, row.ip_address ?? user?.ip_address ?? null);
}
const countryByRowIndex = new Map<number, string | null>();
await Promise.all(
[...ipByRowIndex.entries()].map(async ([index, ip]) => {
countryByRowIndex.set(index, await countryCodeFromIp(ip));
}),
);
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const ping = Number(row.ping);
const userId = Number(row.user_id);
const matchId = Number(row.match_id);
if (!Number.isFinite(ping) || !Number.isFinite(userId) || !Number.isFinite(matchId)) {
continue;
}
allPings.push(ping);
uniquePlayers.add(userId);
uniqueMatches.add(matchId);
const matchKey = String(matchId);
const matchState = byMatch.get(matchKey) ?? {
label: `M${matchId}`,
pings: [],
players: new Set<number>(),
};
matchState.pings.push(ping);
matchState.players.add(userId);
byMatch.set(matchKey, matchState);
const user = row.user?.[0] ?? null;
const username = user?.username?.trim();
const playerLabel = username ? `${username} (${userId})` : `User ${userId}`;
const playerKey = String(userId);
const playerState = byPlayer.get(playerKey) ?? {
label: playerLabel,
pings: [],
players: new Set<number>(),
};
playerState.pings.push(ping);
playerState.players.add(userId);
byPlayer.set(playerKey, playerState);
const country = countryByRowIndex.get(i) ?? "UNK";
const countryFlag = countryFlagFromCode(country);
const countryWithFlag = countryFlag ? `${countryFlag} ${country}` : country;
const countryState = byCountry.get(country) ?? {
label: countryWithFlag,
pings: [],
players: new Set<number>(),
};
countryState.pings.push(ping);
countryState.players.add(userId);
byCountry.set(country, countryState);
}
const sortedPings = [...allPings].sort((a, b) => a - b);
const badReports = allPings.filter((value) => value >= BAD_PING_THRESHOLD_MS).length;
const toRankedRows = (
src: Map<string, { label: string; pings: number[]; players: Set<number> }>,
): PingGroupRow[] =>
[...src.entries()]
.map(([key, state]) => toGroupRow(key, state.label, state.pings, state.players))
.sort((a, b) => b.avgPing - a.avgPing || b.reports - a.reports)
.slice(0, TOP_ROWS_LIMIT);
return {
summary: {
totalReports: allPings.length,
totalMatches: uniqueMatches.size,
totalPlayers: uniquePlayers.size,
avgPing:
allPings.length > 0
? allPings.reduce((acc, value) => acc + value, 0) / allPings.length
: null,
p95Ping: percentile(sortedPings, 0.95),
badThresholdMs: BAD_PING_THRESHOLD_MS,
badReports,
badRatePercent:
allPings.length > 0 ? (badReports / allPings.length) * 100 : null,
},
byMatches: toRankedRows(byMatch),
byPlayers: toRankedRows(byPlayer),
byCountries: toRankedRows(byCountry),
error: null,
};
}
+10 -1
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DbMatch, DbTransaction } from "@/types/database";
import { txAmountToBigInt } from "@/lib/ledger-integrity";
import { countryCodeFromIp } from "@/lib/ip-geolocation";
const TX_PAGE = 5000;
/** Recent matches listed on the player card (newest first). */
@@ -22,8 +23,11 @@ export type PlayerCardMatchHistoryRow = {
export type PlayerCardData = {
id: number;
username: string | null;
email: string | null;
createdAt: string | null;
lastSeen: string | null;
lastLoggedInIp: string | null;
lastLoggedInCountryCode: string | null;
rcBalance: number | null;
ccBalance: number | null;
matchesPlayed: number;
@@ -83,7 +87,7 @@ export async function fetchPlayerCardData(
const { data: userRow, error: userErr } = await supabase
.from("users")
.select("id, username, created_at, last_logged_at, rc, cc")
.select("id, username, email, created_at, last_logged_at, ip_address, rc, cc")
.eq("id", userId)
.maybeSingle();
@@ -270,8 +274,13 @@ export async function fetchPlayerCardData(
data: {
id: userRow.id as number,
username: (userRow.username as string | null) ?? null,
email: (userRow.email as string | null) ?? null,
createdAt: (userRow.created_at as string) ?? null,
lastSeen: (userRow.last_logged_at as string | null) ?? null,
lastLoggedInIp: (userRow.ip_address as string | null) ?? null,
lastLoggedInCountryCode: await countryCodeFromIp(
(userRow.ip_address as string | null) ?? null,
),
rcBalance: (userRow.rc as number | null) ?? null,
ccBalance: (userRow.cc as number | null) ?? null,
matchesPlayed,
+147
View File
@@ -0,0 +1,147 @@
import type {
ReplayEntity,
ReplayEvent,
ReplayFile,
ReplayFrame,
} from "@/types/replay";
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function asNumber(v: unknown, label: string): number {
if (typeof v !== "number" || !Number.isFinite(v)) {
throw new Error(`Invalid ${label}: expected a finite number`);
}
return v;
}
function asBoolean(v: unknown, label: string): boolean {
if (typeof v !== "boolean") {
throw new Error(`Invalid ${label}: expected a boolean`);
}
return v;
}
function asString(v: unknown, label: string): string {
if (typeof v !== "string") {
throw new Error(`Invalid ${label}: expected a string`);
}
return v;
}
function asNumberArray(v: unknown, label: string, len: number): number[] {
if (!Array.isArray(v)) {
throw new Error(`Invalid ${label}: expected an array`);
}
if (v.length !== len) {
throw new Error(
`Invalid ${label}: expected length ${len}, got ${v.length}`,
);
}
const out: number[] = [];
for (let i = 0; i < v.length; i++) {
const n = v[i];
if (typeof n !== "number" || !Number.isFinite(n)) {
throw new Error(`Invalid ${label}[${i}]: expected a finite number`);
}
out.push(n);
}
return out;
}
function parseEntity(raw: unknown, index: number): ReplayEntity {
if (!isRecord(raw)) {
throw new Error(`entities[${index}] must be an object`);
}
return {
id: asNumber(raw.id, `entities[${index}].id`),
type: asString(raw.type, `entities[${index}].type`),
team: asString(raw.team, `entities[${index}].team`),
};
}
function parseFrame(
raw: unknown,
index: number,
entityCount: number,
): ReplayFrame {
if (!isRecord(raw)) {
throw new Error(`frames[${index}] must be an object`);
}
return {
t: asNumber(raw.t, `frames[${index}].t`),
x: asNumberArray(raw.x, `frames[${index}].x`, entityCount),
y: asNumberArray(raw.y, `frames[${index}].y`, entityCount),
vx: asNumberArray(raw.vx, `frames[${index}].vx`, entityCount),
vy: asNumberArray(raw.vy, `frames[${index}].vy`, entityCount),
};
}
function parseEvent(raw: unknown, index: number): ReplayEvent {
if (!isRecord(raw)) {
throw new Error(`events[${index}] must be an object`);
}
return {
t: asNumber(raw.t, `events[${index}].t`),
type: asString(raw.type, `events[${index}].type`),
kind: asString(raw.kind ?? "", `events[${index}].kind`),
vol: asNumber(raw.vol ?? 0, `events[${index}].vol`),
id: asNumber(raw.id ?? -1, `events[${index}].id`),
team: asString(raw.team ?? "", `events[${index}].team`),
redScore: asNumber(raw.redScore ?? 0, `events[${index}].redScore`),
blueScore: asNumber(raw.blueScore ?? 0, `events[${index}].blueScore`),
kickoff: asBoolean(raw.kickoff ?? false, `events[${index}].kickoff`),
};
}
/** Parse and lightly validate a replay JSON value from disk / upload. */
export function parseReplayFile(raw: unknown): ReplayFile {
if (!isRecord(raw)) {
throw new Error("Replay root must be a JSON object");
}
const entitiesRaw = raw.entities;
if (!Array.isArray(entitiesRaw) || entitiesRaw.length === 0) {
throw new Error("Replay must include a non-empty entities array");
}
const entities = entitiesRaw.map(parseEntity);
const framesRaw = raw.frames;
if (!Array.isArray(framesRaw) || framesRaw.length === 0) {
throw new Error("Replay must include a non-empty frames array");
}
const frames = framesRaw.map((f, i) => parseFrame(f, i, entities.length));
const eventsRaw = raw.events;
if (!Array.isArray(eventsRaw)) {
throw new Error("Replay must include an events array");
}
const events = eventsRaw.map(parseEvent);
const duration = asNumber(raw.duration, "duration");
if (duration < 0) {
throw new Error("duration must be >= 0");
}
return {
version: asNumber(raw.version, "version"),
matchId: asNumber(raw.matchId, "matchId"),
isPractice: asBoolean(raw.isPractice ?? false, "isPractice"),
fixedDeltaTime: asNumber(raw.fixedDeltaTime, "fixedDeltaTime"),
duration,
entities,
frames,
events,
};
}
export function parseReplayJsonText(text: string): ReplayFile {
let raw: unknown;
try {
raw = JSON.parse(text) as unknown;
} catch {
throw new Error("File is not valid JSON");
}
return parseReplayFile(raw);
}
+150
View File
@@ -0,0 +1,150 @@
import type {
ReplayEvent,
ReplayFrame,
ReplayPose,
} from "@/types/replay";
function lerp(a: number, b: number, u: number): number {
return a + (b - a) * u;
}
function hermite(
p0: number,
v0: number,
p1: number,
v1: number,
dt: number,
u: number,
): number {
const u2 = u * u;
const u3 = u2 * u;
const h00 = 2 * u3 - 3 * u2 + 1;
const h10 = u3 - 2 * u2 + u;
const h01 = -2 * u3 + 3 * u2;
const h11 = u3 - u2;
return h00 * p0 + h10 * dt * v0 + h01 * p1 + h11 * dt * v1;
}
/** Sorted discontinuity times from goal / reset events. */
export function discontinuityTimes(events: ReplayEvent[]): number[] {
const out: number[] = [];
for (const e of events) {
if (e.type === "goal" || e.type === "reset") {
out.push(e.t);
}
}
out.sort((a, b) => a - b);
return out;
}
/** Cut time in (t0, t1], or null if none. */
function cutBetween(t0: number, t1: number, cuts: number[]): number | null {
for (const c of cuts) {
if (c > t0 && c <= t1) return c;
}
return null;
}
/**
* Largest i such that frames[i].t <= time.
* Returns 0 if time is before the first sample.
*/
export function frameIndexAtOrBefore(
frames: ReplayFrame[],
time: number,
): number {
let lo = 0;
let hi = frames.length - 1;
if (time <= frames[0]!.t) return 0;
if (time >= frames[hi]!.t) return hi;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (frames[mid]!.t <= time) lo = mid;
else hi = mid - 1;
}
return lo;
}
/** First event index with t > time (cursor after last fired). */
export function eventCursorAfter(events: ReplayEvent[], time: number): number {
let lo = 0;
let hi = events.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (events[mid]!.t <= time) lo = mid + 1;
else hi = mid;
}
return lo;
}
export function scoreAtTime(
events: ReplayEvent[],
time: number,
): { red: number; blue: number } {
let red = 0;
let blue = 0;
for (const e of events) {
if (e.t > time) break;
if (e.type === "goal") {
red = e.redScore;
blue = e.blueScore;
}
}
return { red, blue };
}
export function resolvePoses(
frames: ReplayFrame[],
entityCount: number,
time: number,
cuts: number[],
): ReplayPose[] {
const poses: ReplayPose[] = Array.from({ length: entityCount }, () => ({
x: 0,
y: 0,
}));
if (frames.length === 0 || entityCount === 0) return poses;
const i = frameIndexAtOrBefore(frames, time);
const a = frames[i]!;
if (i >= frames.length - 1 || time <= a.t) {
for (let e = 0; e < entityCount; e++) {
poses[e] = { x: a.x[e]!, y: a.y[e]! };
}
return poses;
}
const b = frames[i + 1]!;
const dt = b.t - a.t;
const cut = cutBetween(a.t, b.t, cuts);
if (dt <= 1e-9 || cut != null) {
// No blend across goal/reset: pre-cut → sample a, post-cut → sample b.
const snap = cut != null && time >= cut ? b : a;
for (let e = 0; e < entityCount; e++) {
poses[e] = { x: snap.x[e]!, y: snap.y[e]! };
}
return poses;
}
const u = (time - a.t) / dt;
for (let e = 0; e < entityCount; e++) {
poses[e] = {
x: hermite(a.x[e]!, a.vx[e]!, b.x[e]!, b.vx[e]!, dt, u),
y: hermite(a.y[e]!, a.vy[e]!, b.y[e]!, b.vy[e]!, dt, u),
};
}
return poses;
}
export function formatReplayTime(seconds: number): string {
const s = Math.max(0, seconds);
const m = Math.floor(s / 60);
const rem = s - m * 60;
const whole = Math.floor(rem);
const frac = Math.floor((rem - whole) * 10);
return `${m}:${String(whole).padStart(2, "0")}.${frac}`;
}
export { lerp };
+17 -1
View File
@@ -34,6 +34,20 @@ function normalizeMergedComLoginHost(hostWithOptionalPort: string): string {
return fixed + portPart;
}
function isLocalHost(host: string): boolean {
const h = host.split(":")[0]?.replace(/^\[|\]$/g, "").toLowerCase() ?? "";
return h === "localhost" || h === "127.0.0.1" || h === "::1";
}
/** Infer HTTPS when TLS terminates at a reverse proxy that omits X-Forwarded-Proto. */
function defaultProtoForHost(host: string, requestProtocol: string): "http" | "https" {
if (requestProtocol === "https:") return "https";
if (process.env.NODE_ENV === "production" && host && !isLocalHost(host)) {
return "https";
}
return "http";
}
function parseForwardedHeader(value: string | null): {
host?: string;
proto?: string;
@@ -83,7 +97,9 @@ export function getRequestOrigin(request: Request): string {
const xfProto = request.headers.get("x-forwarded-proto");
const protoRaw = firstUrlLine(
fwd.proto ?? xfProto?.split(",")[0]?.trim() ?? (url.protocol === "https:" ? "https" : "http"),
fwd.proto ??
xfProto?.split(",")[0]?.trim() ??
defaultProtoForHost(hostRaw, url.protocol),
);
const proto = protoRaw === "https" || protoRaw === "http" ? protoRaw : "http";
+4 -1
View File
@@ -1,10 +1,13 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { hasValidSessionToken } from "@/lib/auth/roles";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { publicRequestUrl } from "@/lib/request-public-url";
function hasValidSession(request: NextRequest): boolean {
return request.cookies.get(ADMIN_SESSION_COOKIE)?.value === "1";
return hasValidSessionToken(
request.cookies.get(ADMIN_SESSION_COOKIE)?.value,
);
}
export function proxy(request: NextRequest) {
+14
View File
@@ -3,7 +3,9 @@ export type DbUser = {
id: number;
created_at: string;
username: string | null;
email: string | null;
password: string | null;
ip_address: string | null;
cc: number | null;
rc: number | null;
last_logged_at: string | null;
@@ -29,6 +31,8 @@ export type DbMatch = {
export type AdminMatchRow = DbMatch & {
/** Per-player `entry_hold` debit in coins (stringified bigint); null if unknown. */
entryHoldPerPlayerCoins: string | null;
/** Per-player `entry_fee` debit in coins from `transactions`; null if unknown. */
entryFeePerPlayerCoins: string | null;
};
/** Mirrors `public.settings` (key/value config rows). */
@@ -50,3 +54,13 @@ export type DbTransaction = {
remarks: string | null;
match_id: number | null;
};
/** Mirrors `public.ping_reports` (see schemas/ping_reports.md). */
export type DbPingReport = {
id: number;
created_at: string;
user_id: number;
match_id: number;
ip_address: string | null;
ping: number;
};
+99
View File
@@ -0,0 +1,99 @@
export interface ReplayFile {
version: number;
matchId: number;
isPractice: boolean;
fixedDeltaTime: number;
duration: number;
entities: ReplayEntity[];
frames: ReplayFrame[];
events: ReplayEvent[];
}
export interface ReplayEntity {
id: number;
type: "ball" | "puck" | string;
team: "Red" | "Blue" | "" | string;
}
export interface ReplayFrame {
t: number;
x: number[];
y: number[];
vx: number[];
vy: number[];
}
export interface ReplayEvent {
t: number;
type: "hit" | "goal" | "reset" | string;
kind: "puck_puck" | "ball_puck" | "ball_wall" | "" | string;
vol: number;
id: number;
team: "Red" | "Blue" | "" | string;
redScore: number;
blueScore: number;
kickoff: boolean;
}
export type ReplayPose = {
x: number;
y: number;
};
export type ReplayWorldBounds = {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
/** Axis-aligned goal box in Unity world space (center + size). */
export type ReplayGoalBox = {
x: number;
y: number;
width: number;
height: number;
};
/**
* Playable pitch corners (origin at center, +Y up):
* TL (-2.1, 4.3) TR (2.1, 4.3)
* BL (-2.1, -4.3) BR (2.1, -4.3)
*/
export const REPLAY_PITCH_BOUNDS: ReplayWorldBounds = {
minX: -2.1,
maxX: 2.1,
minY: -4.3,
maxY: 4.3,
};
export const REPLAY_TOP_GOAL: ReplayGoalBox = {
x: 0,
y: 4.87,
width: 1.92,
height: 0.61,
};
export const REPLAY_BOTTOM_GOAL: ReplayGoalBox = {
x: 0,
y: -4.9135,
width: 1.92,
height: 0.61,
};
function goalMinY(g: ReplayGoalBox): number {
return g.y - g.height / 2;
}
function goalMaxY(g: ReplayGoalBox): number {
return g.y + g.height / 2;
}
/** Full view extents: pitch plus goal boxes beyond the end lines. */
export const REPLAY_FIELD_BOUNDS: ReplayWorldBounds = {
minX: REPLAY_PITCH_BOUNDS.minX,
maxX: REPLAY_PITCH_BOUNDS.maxX,
minY: goalMinY(REPLAY_BOTTOM_GOAL),
maxY: goalMaxY(REPLAY_TOP_GOAL),
};