Compare commits
10
Commits
e5414a9920
...
aca0c17e4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca0c17e4d | ||
|
|
6f7af751e1 | ||
|
|
540f6e0af1 | ||
|
|
e324a95625 | ||
|
|
4ed4a10eae | ||
|
|
dc8377177a | ||
|
|
975cc5f1d9 | ||
|
|
9bb0af59b0 | ||
|
|
0bb486223e | ||
|
|
fcf7654e78 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
@@ -1,7 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+363
-190
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 frame’s `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 frame’s 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 event’s `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 event’s `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 `0–0`).
|
||||
4. Reset the event-fire cursor to that time so SFX don’t spam.
|
||||
|
||||
### Scoreboard without scrubbing
|
||||
|
||||
Start at `0–0`. On each `goal` event, set scores from that event’s `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,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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,7 +32,9 @@ export async function updateUserCcRc(formData: FormData) {
|
||||
? "matchmaker"
|
||||
: tabRaw === "ledger"
|
||||
? "ledger"
|
||||
: "dashboard";
|
||||
: tabRaw === "analysis"
|
||||
? "analysis"
|
||||
: "dashboard";
|
||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||
const highlightId = highlightRaw === "" ? null : highlightRaw;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+205
-18
@@ -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"
|
||||
: "dashboard";
|
||||
: 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,31 +238,69 @@ 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(
|
||||
supabase,
|
||||
rawMatches
|
||||
.map((m) => Number(m.id))
|
||||
.filter((id) => Number.isFinite(id)),
|
||||
);
|
||||
const { holdPerPlayer, feePerPlayer } =
|
||||
await fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
|
||||
supabase,
|
||||
rawMatches
|
||||
.map((m) => Number(m.id))
|
||||
.filter((id) => Number.isFinite(id)),
|
||||
);
|
||||
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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,67 +467,121 @@ export function AdminDashboard({
|
||||
>
|
||||
Overview
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "players",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "players")}
|
||||
scroll={false}
|
||||
>
|
||||
Players (
|
||||
{totalUsersLabel.toLocaleString("en-US")}
|
||||
{!statsBundle?.stats
|
||||
? ` · table ${users.length.toLocaleString("en-US")}`
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matches",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "matches")}
|
||||
scroll={false}
|
||||
>
|
||||
Matches (
|
||||
{totalMatchesLabel.toLocaleString("en-US")}
|
||||
{!statsBundle?.stats
|
||||
? ` · table ${matches.length.toLocaleString("en-US")}`
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matchmaker",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "matchmaker")}
|
||||
scroll={false}
|
||||
>
|
||||
Matchmaker
|
||||
</Link>
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "ledger",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "ledger")}
|
||||
scroll={false}
|
||||
>
|
||||
Ledger
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className={tabClass(false)}
|
||||
scroll={false}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
{pageAccess.players ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "players",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "players")}
|
||||
scroll={false}
|
||||
>
|
||||
Players (
|
||||
{totalUsersLabel.toLocaleString("en-US")}
|
||||
{!statsBundle?.stats
|
||||
? ` · table ${users.length.toLocaleString("en-US")}`
|
||||
: null}
|
||||
)
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.matches ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "matches",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
matchesFrom,
|
||||
matchesTo,
|
||||
matchesTzOffsetMinutes,
|
||||
})}
|
||||
className={tabClass(tab === "matches")}
|
||||
scroll={false}
|
||||
>
|
||||
Matches (
|
||||
{totalMatchesLabel.toLocaleString("en-US")}
|
||||
{!statsBundle?.stats
|
||||
? ` · table ${matches.length.toLocaleString("en-US")}`
|
||||
: 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",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "matchmaker")}
|
||||
scroll={false}
|
||||
>
|
||||
Matchmaker
|
||||
</Link>
|
||||
) : null}
|
||||
{pageAccess.ledger ? (
|
||||
<Link
|
||||
href={buildDashboardHref({
|
||||
tab: "ledger",
|
||||
highlightId,
|
||||
participantRaw,
|
||||
})}
|
||||
className={tabClass(tab === "ledger")}
|
||||
scroll={false}
|
||||
>
|
||||
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)}
|
||||
scroll={false}
|
||||
>
|
||||
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,15 +832,20 @@ 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">
|
||||
<Link
|
||||
href={editHref(u)}
|
||||
scroll={false}
|
||||
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"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
{canWritePlayers ? (
|
||||
<Link
|
||||
href={editHref(u)}
|
||||
scroll={false}
|
||||
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"
|
||||
>
|
||||
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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(" ")}
|
||||
>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,42 +18,75 @@ 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"
|
||||
>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`setting-value-${index}`}
|
||||
<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 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"
|
||||
htmlFor={`setting-value-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`setting-value-${index}`}
|
||||
name="value"
|
||||
type="text"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`setting-value-${index}`}
|
||||
name="value"
|
||||
type="text"
|
||||
defaultValue={row.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"
|
||||
/>
|
||||
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="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"
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,50 +98,64 @@ 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"
|
||||
>
|
||||
<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">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`bet-fee-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
<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">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`bet-fee-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
0–100 (saved as the number shown).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<input
|
||||
id={`bet-fee-${index}`}
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={v}
|
||||
onChange={(e) => setV(Number(e.target.value))}
|
||||
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
|
||||
/>
|
||||
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{v}
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
0–100 (saved as the number shown).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<input
|
||||
id={`bet-fee-${index}`}
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={v}
|
||||
onChange={(e) => setV(Number(e.target.value))}
|
||||
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
|
||||
/>
|
||||
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
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="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"
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,79 +171,103 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
||||
return rcToCoins(Number(t));
|
||||
}, [rcText]);
|
||||
|
||||
const saveDisabled = rcText.trim() === "";
|
||||
|
||||
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"
|
||||
onSubmit={(e) => {
|
||||
setLocalError(null);
|
||||
const trimmed = rcText.trim();
|
||||
if (trimmed === "") {
|
||||
e.preventDefault();
|
||||
setLocalError("Enter an RC value (one decimal place; tenths digit 0–3).");
|
||||
return;
|
||||
}
|
||||
const rc = Number(trimmed);
|
||||
const coins = rcToCoins(rc);
|
||||
if (coins === null) {
|
||||
e.preventDefault();
|
||||
setLocalError(
|
||||
"Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="value"
|
||||
value={coinsPreview === null ? "" : String(coinsPreview)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`entry-fee-rc-${index}`}
|
||||
<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}
|
||||
onSubmit={(e) => {
|
||||
setLocalError(null);
|
||||
const trimmed = rcText.trim();
|
||||
if (trimmed === "") {
|
||||
e.preventDefault();
|
||||
setLocalError(
|
||||
"Enter an RC value (one decimal place; tenths digit 0–3).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rc = Number(trimmed);
|
||||
const coins = rcToCoins(rc);
|
||||
if (coins === null) {
|
||||
e.preventDefault();
|
||||
setLocalError(
|
||||
"Enter a valid RC with one decimal place; the tenths digit must be 0–3 (e.g. 5.1).",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="key" value={row.key} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="value"
|
||||
value={coinsPreview === null ? "" : String(coinsPreview)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label
|
||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||
htmlFor={`entry-fee-rc-${index}`}
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
|
||||
(edit as RC; stored as coins)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`entry-fee-rc-${index}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={rcText}
|
||||
onChange={(e) => setRcText(e.target.value)}
|
||||
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"
|
||||
>
|
||||
{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.
|
||||
</p>
|
||||
) : rcText.trim() !== "" ? (
|
||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Not a valid RC encoding yet — fix the value to save.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
|
||||
{row.key}
|
||||
</span>
|
||||
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
|
||||
(edit as RC; stored as coins)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`entry-fee-rc-${index}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
value={rcText}
|
||||
onChange={(e) => setRcText(e.target.value)}
|
||||
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">
|
||||
{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.
|
||||
</p>
|
||||
) : rcText.trim() !== "" ? (
|
||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Not a valid RC encoding yet — fix the value to save.
|
||||
</p>
|
||||
) : null}
|
||||
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="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"
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,7 +90,9 @@ export function EditUserCcRcOverlay({
|
||||
? "matchmaker"
|
||||
: tab === "ledger"
|
||||
? "ledger"
|
||||
: "dashboard"
|
||||
: tab === "analysis"
|
||||
? "analysis"
|
||||
: "dashboard"
|
||||
}
|
||||
/>
|
||||
<input
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/{matchId}_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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,10 +10,20 @@ 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;
|
||||
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) {
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}` : "/";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}]`;
|
||||
}
|
||||
@@ -6,51 +6,95 @@ 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) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const sums = new Map<number, bigint>();
|
||||
const counts = new Map<number, number>();
|
||||
|
||||
for (const row of (data ?? []) as Pick<
|
||||
DbTransaction,
|
||||
"match_id" | "amount" | "remarks"
|
||||
>[]) {
|
||||
if (normRemark(row.remarks) !== "entry_hold") {
|
||||
continue;
|
||||
}
|
||||
const midRaw = row.match_id;
|
||||
if (midRaw == null) continue;
|
||||
const mid =
|
||||
typeof midRaw === "number" ? midRaw : Math.trunc(Number(midRaw));
|
||||
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);
|
||||
}
|
||||
|
||||
for (const [mid, sum] of sums) {
|
||||
const c = counts.get(mid) ?? 0;
|
||||
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;
|
||||
}
|
||||
|
||||
export type MatchEntryHoldAndFeePerPlayerCoins = {
|
||||
holdPerPlayer: Map<number, bigint>;
|
||||
feePerPlayer: Map<number, bigint>;
|
||||
};
|
||||
|
||||
/** 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"
|
||||
>[];
|
||||
|
||||
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 =
|
||||
typeof midRaw === "number" ? midRaw : Math.trunc(Number(midRaw));
|
||||
if (!Number.isFinite(mid)) continue;
|
||||
const a = txAmountToBigInt(row.amount);
|
||||
if (a === BigInt(0)) continue;
|
||||
if (rmk === "entry_hold") bumpAgg(holdAgg, mid, a);
|
||||
else bumpAgg(feeAgg, mid, a);
|
||||
}
|
||||
|
||||
if (batch.length < TX_PAGE_SIZE) break;
|
||||
offset += TX_PAGE_SIZE;
|
||||
}
|
||||
|
||||
return {
|
||||
holdPerPlayer: avgPerMatch(holdAgg),
|
||||
feePerPlayer: avgPerMatch(feeAgg),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user