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).
|
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
|
||||||
# Example: https://kickkings.playpoolstudios.com
|
# Example: https://kickkings.playpoolstudios.com
|
||||||
# APP_ORIGIN=
|
# 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).
|
# Directory containing matchmaker logs: history.log (processed) and matchmaker.log (raw).
|
||||||
# Example: /var/www/html/kickkings/logs/matchmaker/logs
|
# Example: /var/www/html/kickkings/logs/matchmaker/logs
|
||||||
# MATCHMAKER_LOGS_DIR=
|
# 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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {};
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+363
-190
@@ -9,15 +9,17 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.102.1",
|
"@supabase/supabase-js": "^2.102.1",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.10",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-simple-maps": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@@ -38,13 +40,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
@@ -53,9 +55,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -63,22 +65,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.29.7",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.29.7",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
@@ -95,14 +96,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -112,14 +113,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.28.6",
|
"@babel/compat-data": "^7.29.7",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
"browserslist": "^4.24.0",
|
"browserslist": "^4.24.0",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
@@ -129,9 +130,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -139,29 +140,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-imports": {
|
"node_modules/@babel/helper-module-imports": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.28.6",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.28.6",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.6"
|
"@babel/traverse": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -171,9 +172,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -181,9 +182,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -191,9 +192,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-option": {
|
"node_modules/@babel/helper-validator-option": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -201,27 +202,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -231,33 +232,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -265,14 +266,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -1037,9 +1038,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
|
||||||
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
|
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -1053,9 +1054,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
|
||||||
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
|
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1069,9 +1070,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
|
||||||
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
|
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1085,9 +1086,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
|
||||||
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
|
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1101,9 +1102,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
|
||||||
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
|
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1117,9 +1118,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
|
||||||
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
|
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1133,9 +1134,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
|
||||||
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
|
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1149,9 +1150,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
|
||||||
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
|
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1165,9 +1166,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
|
||||||
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
|
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1612,6 +1613,51 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1619,6 +1665,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"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==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1663,6 +1715,19 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -1717,7 +1782,6 @@
|
|||||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.58.1",
|
"@typescript-eslint/scope-manager": "8.58.1",
|
||||||
"@typescript-eslint/types": "8.58.1",
|
"@typescript-eslint/types": "8.58.1",
|
||||||
@@ -1872,9 +1936,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2243,7 +2307,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2587,7 +2650,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -2725,6 +2787,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -2761,6 +2829,105 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -3156,7 +3323,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3342,7 +3508,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -3975,9 +4140,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4065,6 +4230,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -4526,14 +4697,23 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -4937,7 +5117,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -5031,9 +5210,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5072,12 +5251,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.2.6",
|
"version": "16.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
|
||||||
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
|
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.2.6",
|
"@next/env": "16.2.10",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -5091,14 +5270,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.2.6",
|
"@next/swc-darwin-arm64": "16.2.10",
|
||||||
"@next/swc-darwin-x64": "16.2.6",
|
"@next/swc-darwin-x64": "16.2.10",
|
||||||
"@next/swc-linux-arm64-gnu": "16.2.6",
|
"@next/swc-linux-arm64-gnu": "16.2.10",
|
||||||
"@next/swc-linux-arm64-musl": "16.2.6",
|
"@next/swc-linux-arm64-musl": "16.2.10",
|
||||||
"@next/swc-linux-x64-gnu": "16.2.6",
|
"@next/swc-linux-x64-gnu": "16.2.10",
|
||||||
"@next/swc-linux-x64-musl": "16.2.6",
|
"@next/swc-linux-x64-musl": "16.2.10",
|
||||||
"@next/swc-win32-arm64-msvc": "16.2.6",
|
"@next/swc-win32-arm64-msvc": "16.2.10",
|
||||||
"@next/swc-win32-x64-msvc": "16.2.6",
|
"@next/swc-win32-x64-msvc": "16.2.10",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"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": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||||
@@ -5182,7 +5333,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5439,10 +5589,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -5459,7 +5608,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -5481,7 +5630,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -5525,7 +5673,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -5535,7 +5682,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -5547,9 +5693,25 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -5839,9 +6001,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp/node_modules/semver": {
|
"node_modules/sharp/node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -6227,7 +6389,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6248,6 +6409,20 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -6390,7 +6565,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6640,9 +6814,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
@@ -6686,7 +6860,6 @@
|
|||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -10,18 +10,24 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.102.1",
|
"@supabase/supabase-js": "^2.102.1",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.10",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-simple-maps": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"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,
|
id bigint generated by default as identity not null,
|
||||||
created_at timestamp with time zone not null default now(),
|
created_at timestamp with time zone not null default now(),
|
||||||
username text null,
|
username text null,
|
||||||
|
email text null,
|
||||||
password text null,
|
password text null,
|
||||||
|
ip_address text null,
|
||||||
cc real null default '0'::real,
|
cc real null default '0'::real,
|
||||||
rc real null default '0'::real,
|
rc real null default '0'::real,
|
||||||
last_logged_at timestamp with time zone null default now(),
|
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";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
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 { parseStoredCoins } from "@/lib/coins-rc";
|
||||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
function normalizeValue(raw: FormDataEntryValue | null): string | null {
|
const EMPTY = "__empty__";
|
||||||
if (raw == null) return null;
|
const INVALID = "__invalid__";
|
||||||
const s = String(raw).trim();
|
|
||||||
return s === "" ? null : s;
|
function normalizeValue(raw: FormDataEntryValue | null): string | typeof EMPTY {
|
||||||
|
const s = raw == null ? "" : String(raw).trim();
|
||||||
|
return s === "" ? EMPTY : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSettingValue(
|
function normalizeSettingValue(
|
||||||
key: string,
|
key: string,
|
||||||
raw: FormDataEntryValue | null,
|
raw: FormDataEntryValue | null,
|
||||||
): string | null {
|
): string | typeof EMPTY | typeof INVALID {
|
||||||
const s = raw == null ? "" : String(raw).trim();
|
const s = raw == null ? "" : String(raw).trim();
|
||||||
|
|
||||||
if (key === "entry_fee") {
|
if (key === "entry_fee") {
|
||||||
if (s === "") return null;
|
if (s === "") return EMPTY;
|
||||||
const coins = parseStoredCoins(s);
|
const coins = parseStoredCoins(s);
|
||||||
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
|
if (!Number.isFinite(coins) || coins < 0) return "__invalid__";
|
||||||
return String(coins);
|
return String(coins);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "bet_fee") {
|
if (key === "bet_fee") {
|
||||||
if (s === "") return null;
|
if (s === "") return EMPTY;
|
||||||
const n = Math.round(Number(s));
|
const n = Math.round(Number(s));
|
||||||
if (!Number.isFinite(n)) return "__invalid__";
|
if (!Number.isFinite(n)) return "__invalid__";
|
||||||
return String(Math.min(100, Math.max(0, n)));
|
return String(Math.min(100, Math.max(0, n)));
|
||||||
@@ -36,10 +38,7 @@ function normalizeSettingValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSetting(formData: FormData) {
|
export async function updateSetting(formData: FormData) {
|
||||||
const cookieStore = await cookies();
|
const actor = await requireAdmin();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = String(formData.get("key") ?? "").trim();
|
const key = String(formData.get("key") ?? "").trim();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -47,7 +46,7 @@ export async function updateSetting(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const value = normalizeSettingValue(key, formData.get("value"));
|
const value = normalizeSettingValue(key, formData.get("value"));
|
||||||
if (value === "__invalid__") {
|
if (value === INVALID || value === EMPTY) {
|
||||||
redirect("/settings?saveError=1");
|
redirect("/settings?saveError=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,14 +64,19 @@ export async function updateSetting(formData: FormData) {
|
|||||||
redirect("/settings?saveError=1");
|
redirect("/settings?saveError=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "settings.update",
|
||||||
|
summary: `Updated setting “${key}”`,
|
||||||
|
details: { key, value },
|
||||||
|
});
|
||||||
|
|
||||||
redirect("/settings");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function insertSetting(formData: FormData) {
|
export async function insertSetting(formData: FormData) {
|
||||||
const cookieStore = await cookies();
|
const actor = await requireAdmin();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = String(formData.get("newKey") ?? "").trim();
|
const key = String(formData.get("newKey") ?? "").trim();
|
||||||
const value = normalizeValue(formData.get("newValue"));
|
const value = normalizeValue(formData.get("newValue"));
|
||||||
@@ -80,6 +84,9 @@ export async function insertSetting(formData: FormData) {
|
|||||||
if (!key) {
|
if (!key) {
|
||||||
redirect("/settings?addError=missing");
|
redirect("/settings?addError=missing");
|
||||||
}
|
}
|
||||||
|
if (value === EMPTY) {
|
||||||
|
redirect("/settings?addError=missingValue");
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = createAdminSupabase();
|
const supabase = createAdminSupabase();
|
||||||
if (!supabase) {
|
if (!supabase) {
|
||||||
@@ -93,5 +100,42 @@ export async function insertSetting(formData: FormData) {
|
|||||||
redirect(`/settings?addError=${code}`);
|
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");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
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 {
|
import {
|
||||||
buildDashboardHref,
|
buildDashboardHref,
|
||||||
type AdminDashboardTab,
|
type AdminDashboardTab,
|
||||||
@@ -19,10 +19,7 @@ function parseScoreField(raw: FormDataEntryValue | null): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserCcRc(formData: FormData) {
|
export async function updateUserCcRc(formData: FormData) {
|
||||||
const cookieStore = await cookies();
|
const actor = await requirePageWrite("players");
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const tabRaw = String(formData.get("tab") ?? "");
|
const tabRaw = String(formData.get("tab") ?? "");
|
||||||
@@ -35,6 +32,8 @@ export async function updateUserCcRc(formData: FormData) {
|
|||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: tabRaw === "ledger"
|
: tabRaw === "ledger"
|
||||||
? "ledger"
|
? "ledger"
|
||||||
|
: tabRaw === "analysis"
|
||||||
|
? "analysis"
|
||||||
: "dashboard";
|
: "dashboard";
|
||||||
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
const highlightRaw = String(formData.get("highlightId") ?? "").trim();
|
||||||
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
const participantRaw = String(formData.get("participantRaw") ?? "").trim();
|
||||||
@@ -125,5 +124,13 @@ export async function updateUserCcRc(formData: FormData) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await appendAuditLog({
|
||||||
|
username: actor.username,
|
||||||
|
accountId: actor.id,
|
||||||
|
action: "players.update_cc_rc",
|
||||||
|
summary: `Updated CC/RC for player #${userId}`,
|
||||||
|
details: { userId, cc, rc },
|
||||||
|
});
|
||||||
|
|
||||||
redirect(buildDashboardHref(base));
|
redirect(buildDashboardHref(base));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
import {
|
import {
|
||||||
applySessionCookie,
|
applySessionCookie,
|
||||||
@@ -10,8 +24,26 @@ function invalidRedirect(request: Request) {
|
|||||||
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
|
return NextResponse.redirect(publicRequestUrl(request, "/login?error=1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidJson() {
|
||||||
|
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const contentType = request.headers.get("content-type") ?? "";
|
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 username: string;
|
||||||
let password: string;
|
let password: string;
|
||||||
@@ -31,24 +63,57 @@ export async function POST(request: Request) {
|
|||||||
password = String(formData.get("password") ?? "");
|
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")) {
|
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);
|
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")) {
|
if (contentType.includes("application/json")) {
|
||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(
|
res.cookies.set(
|
||||||
ADMIN_SESSION_COOKIE,
|
ADMIN_SESSION_COOKIE,
|
||||||
"1",
|
createSessionToken(account.username),
|
||||||
getSessionCookieSetOptions(request),
|
getSessionCookieSetOptions(request),
|
||||||
);
|
);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
||||||
applySessionCookie(res, request);
|
applySessionCookie(res, request, account.username);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
|
import { getSessionCookieClearOptions } from "@/lib/auth/session-cookie";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
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 });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(
|
res.cookies.set(
|
||||||
ADMIN_SESSION_COOKIE,
|
ADMIN_SESSION_COOKIE,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cookies } from "next/headers";
|
import { getSessionAccount } from "@/lib/auth/require-session";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
import { canReadPage } from "@/lib/auth/permissions";
|
||||||
import {
|
import {
|
||||||
parseMatchIdParam,
|
parseMatchIdParam,
|
||||||
readMatchLogFile,
|
readMatchLogFile,
|
||||||
@@ -9,8 +9,11 @@ export async function GET(
|
|||||||
_request: Request,
|
_request: Request,
|
||||||
context: { params: Promise<{ matchId: string }> },
|
context: { params: Promise<{ matchId: string }> },
|
||||||
) {
|
) {
|
||||||
const cookieStore = await cookies();
|
const account = await getSessionAccount();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
if (
|
||||||
|
!account ||
|
||||||
|
(!canReadPage(account, "matches") && !canReadPage(account, "analysis"))
|
||||||
|
) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { cookies } from "next/headers";
|
import { getSessionAccount } from "@/lib/auth/require-session";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
import { canReadPage } from "@/lib/auth/permissions";
|
||||||
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
|
||||||
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const cookieStore = await cookies();
|
const account = await getSessionAccount();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
if (!account || !canReadPage(account, "matchmaker")) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Unauthorized" },
|
{ error: "Unauthorized" },
|
||||||
{ status: 401, headers: NO_STORE },
|
{ status: 401, headers: NO_STORE },
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
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 {
|
import {
|
||||||
formatRcDecimalFromCoinsBigInt,
|
formatRcDecimalFromCoinsBigInt,
|
||||||
formatRcLabelFromCoinsBigInt,
|
formatRcLabelFromCoinsBigInt,
|
||||||
@@ -58,10 +58,8 @@ export default async function LedgerBookPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
|
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
const account = await requirePageRead("ledger");
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
await logPageAccess(account, "ledger-book");
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
let fromStr = firstSearchParam(sp.from);
|
let fromStr = firstSearchParam(sp.from);
|
||||||
@@ -151,7 +149,7 @@ export default async function LedgerBookPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col bg-amber-50/40 dark:bg-zinc-950">
|
<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">
|
<main className="flex-1 space-y-6 px-6 py-8">
|
||||||
<div className="mx-auto max-w-[1200px] space-y-2">
|
<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">
|
<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) {
|
export default async function LoginPage({ searchParams }: Props) {
|
||||||
const { error } = await searchParams;
|
const { error } = await searchParams;
|
||||||
const invalid = error === "1";
|
const invalid = error === "1";
|
||||||
|
const locked = error === "locked";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 items-center justify-center bg-zinc-100 px-4 dark:bg-zinc-950">
|
<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">
|
<h1 className="text-center text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
Admin sign in
|
Admin sign in
|
||||||
</h1>
|
</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
|
<form
|
||||||
action="/api/auth/login"
|
action="/api/auth/login"
|
||||||
method="post"
|
method="post"
|
||||||
@@ -58,6 +49,11 @@ export default async function LoginPage({ searchParams }: Props) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{invalid ? (
|
||||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
Invalid username or password.
|
Invalid username or password.
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
import { notFound } from "next/navigation";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { canReadPage } from "@/lib/auth/permissions";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
import { logPageAccess } from "@/lib/auth/log-page-access";
|
||||||
|
import { requireSession } from "@/lib/auth/require-session";
|
||||||
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
||||||
import {
|
import {
|
||||||
parseMatchIdParam,
|
parseMatchIdParam,
|
||||||
readMatchLogFile,
|
readMatchLogFile,
|
||||||
} from "@/lib/match-logs-server";
|
} from "@/lib/match-logs-server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -25,9 +27,12 @@ export default async function MatchLogPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ matchId: string }>;
|
params: Promise<{ matchId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
const account = await requireSession();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
if (
|
||||||
redirect("/login");
|
!canReadPage(account, "matches") &&
|
||||||
|
!canReadPage(account, "analysis")
|
||||||
|
) {
|
||||||
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { matchId: raw } = await params;
|
const { matchId: raw } = await params;
|
||||||
@@ -36,6 +41,8 @@ export default async function MatchLogPage({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await logPageAccess(account, "match-log", { matchId });
|
||||||
|
|
||||||
const result = await readMatchLogFile(matchId);
|
const result = await readMatchLogFile(matchId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
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 { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
|
||||||
@@ -17,10 +16,8 @@ export default async function MatchmakerLogsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ mklog?: string | string[] }>;
|
searchParams: Promise<{ mklog?: string | string[] }>;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
const account = await requirePageRead("matchmaker");
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
await logPageAccess(account, "matchmaker-logs");
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const raw =
|
const raw =
|
||||||
|
|||||||
+199
-12
@@ -1,11 +1,23 @@
|
|||||||
import { AdminDashboard } from "@/components/admin-dashboard";
|
import { AdminDashboard } from "@/components/admin-dashboard";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
|
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 { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||||
import {
|
import {
|
||||||
normalizeLedgerDateRange,
|
normalizeLedgerDateRange,
|
||||||
|
parseIsoDateOnly,
|
||||||
utcLast30DaysDateRange,
|
utcLast30DaysDateRange,
|
||||||
} from "@/lib/ledger-utc-date-range";
|
} from "@/lib/ledger-utc-date-range";
|
||||||
|
import {
|
||||||
|
localDateRangeToUtcIsoBounds,
|
||||||
|
normalizeMatchesDateRange,
|
||||||
|
parseTzOffsetMinutes,
|
||||||
|
} from "@/lib/local-date-range";
|
||||||
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
|
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
|
||||||
import {
|
import {
|
||||||
serializeLedgerGlobalSummary,
|
serializeLedgerGlobalSummary,
|
||||||
@@ -18,7 +30,21 @@ import {
|
|||||||
type MatchmakerLogSource,
|
type MatchmakerLogSource,
|
||||||
} from "@/lib/matchmaker-log-source";
|
} from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
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 {
|
import type {
|
||||||
AdminMatchRow,
|
AdminMatchRow,
|
||||||
DbMatch,
|
DbMatch,
|
||||||
@@ -35,6 +61,9 @@ import {
|
|||||||
type LedgerSortKey,
|
type LedgerSortKey,
|
||||||
type LedgerSortOrder,
|
type LedgerSortOrder,
|
||||||
} from "@/lib/ledger-table-view";
|
} 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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -55,6 +84,7 @@ export default async function Home({
|
|||||||
participant?: string | string[];
|
participant?: string | string[];
|
||||||
edit?: string | string[];
|
edit?: string | string[];
|
||||||
saveError?: string | string[];
|
saveError?: string | string[];
|
||||||
|
supplyErr?: string | string[];
|
||||||
mklog?: string | string[];
|
mklog?: string | string[];
|
||||||
lfrom?: string | string[];
|
lfrom?: string | string[];
|
||||||
lto?: string | string[];
|
lto?: string | string[];
|
||||||
@@ -62,11 +92,29 @@ export default async function Home({
|
|||||||
lsize?: string | string[];
|
lsize?: string | string[];
|
||||||
lsort?: string | string[];
|
lsort?: string | string[];
|
||||||
lorder?: 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 sp = await searchParams;
|
||||||
const tabParam = firstSearchParam(sp.tab);
|
const tabParam = firstSearchParam(sp.tab);
|
||||||
const tab: AdminDashboardTab =
|
const requestedTab: AdminDashboardTab =
|
||||||
tabParam === "matches"
|
tabParam === "matches"
|
||||||
? "matches"
|
? "matches"
|
||||||
: tabParam === "players"
|
: tabParam === "players"
|
||||||
@@ -75,11 +123,27 @@ export default async function Home({
|
|||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: tabParam === "ledger"
|
: tabParam === "ledger"
|
||||||
? "ledger"
|
? "ledger"
|
||||||
|
: tabParam === "analysis"
|
||||||
|
? "analysis"
|
||||||
|
: tabParam === "logs"
|
||||||
|
? "logs"
|
||||||
: "dashboard";
|
: "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 highlightId = firstSearchParam(sp.highlight);
|
||||||
const participantRaw = firstSearchParam(sp.participant);
|
const participantRaw = firstSearchParam(sp.participant);
|
||||||
const editRaw = firstSearchParam(sp.edit);
|
const editRaw = firstSearchParam(sp.edit);
|
||||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||||
|
const supplyError = firstSearchParam(sp.supplyErr) === "1";
|
||||||
const editIdNum =
|
const editIdNum =
|
||||||
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
|
editRaw != null && editRaw !== "" ? Number(editRaw) : NaN;
|
||||||
|
|
||||||
@@ -101,12 +165,43 @@ export default async function Home({
|
|||||||
let ledgerSortOrderView: LedgerSortOrder = "desc";
|
let ledgerSortOrderView: LedgerSortOrder = "desc";
|
||||||
let ledgerFrom = utcLast30DaysDateRange().from;
|
let ledgerFrom = utcLast30DaysDateRange().from;
|
||||||
let ledgerTo = utcLast30DaysDateRange().to;
|
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 =
|
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
let matchmakerSource: MatchmakerLogSource = "processed";
|
let matchmakerSource: MatchmakerLogSource = "processed";
|
||||||
let matchmakerContent = "";
|
let matchmakerContent = "";
|
||||||
let matchmakerError: string | null = null;
|
let matchmakerError: string | null = null;
|
||||||
|
let auditEntries: AuditLogEntry[] = [];
|
||||||
|
let auditError: string | null = null;
|
||||||
if (tab === "matchmaker") {
|
if (tab === "matchmaker") {
|
||||||
const mkRaw = firstSearchParam(sp.mklog);
|
const mkRaw = firstSearchParam(sp.mklog);
|
||||||
matchmakerSource = parseMatchmakerLogParam(mkRaw);
|
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) {
|
if (!supabase) {
|
||||||
configError =
|
configError =
|
||||||
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
"Set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env.local.";
|
||||||
} else {
|
} else {
|
||||||
const usersRes = await supabase
|
const usersRes = await supabase
|
||||||
.from("users")
|
.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 })
|
.order("id", { ascending: false })
|
||||||
.limit(500);
|
.limit(500);
|
||||||
|
|
||||||
@@ -134,17 +238,52 @@ export default async function Home({
|
|||||||
users = (usersRes.data ?? []) as DbUser[];
|
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")
|
.from("matches")
|
||||||
.select("*")
|
.select("*")
|
||||||
.order("id", { ascending: false })
|
.order("id", { ascending: false });
|
||||||
.limit(500);
|
|
||||||
|
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) {
|
if (matchesRes.error) {
|
||||||
matchesError = matchesRes.error.message;
|
matchesError = matchesRes.error.message;
|
||||||
} else {
|
} else {
|
||||||
const rawMatches = (matchesRes.data ?? []) as DbMatch[];
|
const rawMatches = (matchesRes.data ?? []) as DbMatch[];
|
||||||
const holdByMatch = await fetchEntryHoldPerPlayerCoinsByMatchIds(
|
const { holdPerPlayer, feePerPlayer } =
|
||||||
|
await fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
|
||||||
supabase,
|
supabase,
|
||||||
rawMatches
|
rawMatches
|
||||||
.map((m) => Number(m.id))
|
.map((m) => Number(m.id))
|
||||||
@@ -152,13 +291,16 @@ export default async function Home({
|
|||||||
);
|
);
|
||||||
matches = rawMatches.map((m) => {
|
matches = rawMatches.map((m) => {
|
||||||
const idNum = Number(m.id);
|
const idNum = Number(m.id);
|
||||||
const hold = Number.isFinite(idNum)
|
const hold =
|
||||||
? holdByMatch.get(idNum)
|
Number.isFinite(idNum) ? holdPerPlayer.get(idNum) : undefined;
|
||||||
: undefined;
|
const fee =
|
||||||
|
Number.isFinite(idNum) ? feePerPlayer.get(idNum) : undefined;
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
entryHoldPerPlayerCoins:
|
entryHoldPerPlayerCoins:
|
||||||
hold !== undefined ? hold.toString() : null,
|
hold !== undefined ? hold.toString() : null,
|
||||||
|
entryFeePerPlayerCoins:
|
||||||
|
fee !== undefined ? fee.toString() : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -223,16 +365,44 @@ export default async function Home({
|
|||||||
ledgerTotalPagesView = slice.totalPages;
|
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;
|
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;
|
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
<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 ? (
|
{configError ? (
|
||||||
<div className="px-6 pt-8">
|
<div className="px-6 pt-8">
|
||||||
<div
|
<div
|
||||||
@@ -268,6 +438,23 @@ export default async function Home({
|
|||||||
ledgerOrder={ledgerSortOrderView}
|
ledgerOrder={ledgerSortOrderView}
|
||||||
ledgerFrom={ledgerFrom}
|
ledgerFrom={ledgerFrom}
|
||||||
ledgerTo={ledgerTo}
|
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 ? (
|
{editUser ? (
|
||||||
<EditUserCcRcOverlay
|
<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 type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { requireAdmin } from "@/lib/auth/require-session";
|
||||||
import { redirect } from "next/navigation";
|
import { listSessionAccounts } from "@/lib/auth/accounts-store";
|
||||||
|
import { logPageAccess } from "@/lib/auth/log-page-access";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
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 { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
import type { DbSetting } from "@/types/database";
|
import type { DbSetting } from "@/types/database";
|
||||||
|
|
||||||
@@ -29,16 +30,20 @@ export default async function SettingsPage({
|
|||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
saveError?: string | string[];
|
saveError?: string | string[];
|
||||||
addError?: string | string[];
|
addError?: string | string[];
|
||||||
|
accountError?: string | string[];
|
||||||
|
accountOk?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
const account = await requireAdmin();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
await logPageAccess(account, "settings");
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||||
const addError = firstSearchParam(sp.addError);
|
const addError = firstSearchParam(sp.addError);
|
||||||
|
const accountError = firstSearchParam(sp.accountError);
|
||||||
|
const accountOk = firstSearchParam(sp.accountOk);
|
||||||
|
|
||||||
|
const accounts = await listSessionAccounts();
|
||||||
|
|
||||||
const supabase = createAdminSupabase();
|
const supabase = createAdminSupabase();
|
||||||
let rows: DbSetting[] = [];
|
let rows: DbSetting[] = [];
|
||||||
@@ -63,12 +68,27 @@ export default async function SettingsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
<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} />
|
||||||
<main className="flex-1 px-6 py-8">
|
<main className="flex-1 space-y-12 px-6 py-8">
|
||||||
<div className="mx-auto mb-8 max-w-[900px]">
|
<div className="mx-auto max-w-[900px]">
|
||||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
Global settings
|
Settings
|
||||||
</h1>
|
</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">
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
Key/value rows in the{" "}
|
Key/value rows in the{" "}
|
||||||
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
||||||
@@ -96,6 +116,7 @@ export default async function SettingsPage({
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
saveError={saveError}
|
saveError={saveError}
|
||||||
addError={addError}
|
addError={addError}
|
||||||
|
readOnly={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||||
import { AdminLedger } from "@/components/admin-ledger";
|
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 { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
||||||
|
import type { AuditLogEntry } from "@/lib/auth/audit-log";
|
||||||
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
||||||
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
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 { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||||
|
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||||
import {
|
import {
|
||||||
buildDashboardHref,
|
buildDashboardHref,
|
||||||
type AdminDashboardTab,
|
type AdminDashboardTab,
|
||||||
} from "@/lib/dashboard-search-url";
|
} from "@/lib/dashboard-search-url";
|
||||||
|
import {
|
||||||
|
localLast30DaysDateRange,
|
||||||
|
} from "@/lib/local-date-range";
|
||||||
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||||
import type {
|
import type {
|
||||||
AdminMatchRow,
|
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 {
|
function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
|
||||||
if (v == null) return "—";
|
if (v == null) return "—";
|
||||||
const b = txAmountToBigInt(v);
|
const b = txAmountToBigInt(v);
|
||||||
@@ -220,6 +225,33 @@ type Props = {
|
|||||||
matchmakerSource: MatchmakerLogSource;
|
matchmakerSource: MatchmakerLogSource;
|
||||||
matchmakerContent: string;
|
matchmakerContent: string;
|
||||||
matchmakerError: string | null;
|
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({
|
function StatCard({
|
||||||
@@ -269,11 +301,56 @@ export function AdminDashboard({
|
|||||||
matchmakerSource,
|
matchmakerSource,
|
||||||
matchmakerContent,
|
matchmakerContent,
|
||||||
matchmakerError,
|
matchmakerError,
|
||||||
|
supplyError,
|
||||||
|
matchesFrom,
|
||||||
|
matchesTo,
|
||||||
|
matchesTzOffsetMinutes,
|
||||||
|
matchesRangeExplicit,
|
||||||
|
analysisFrom,
|
||||||
|
analysisTo,
|
||||||
|
analysisPlayerIds,
|
||||||
|
matchAnalysis,
|
||||||
|
pingAnalytics,
|
||||||
|
geolocationAnalytics,
|
||||||
|
auditEntries,
|
||||||
|
auditError,
|
||||||
|
pageAccess,
|
||||||
|
canWritePlayers,
|
||||||
|
canWriteLedger,
|
||||||
|
isAdmin,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||||
|
const [playersSearch, setPlayersSearch] = useState("");
|
||||||
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
|
||||||
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
|
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 lbRaw = statsBundle?.leaderboard;
|
||||||
const sortedLeaderboard = useMemo(() => {
|
const sortedLeaderboard = useMemo(() => {
|
||||||
const rows = lbRaw ?? [];
|
const rows = lbRaw ?? [];
|
||||||
@@ -322,6 +399,25 @@ export function AdminDashboard({
|
|||||||
if (!hideNoWinner) return filteredMatches;
|
if (!hideNoWinner) return filteredMatches;
|
||||||
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
|
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
|
||||||
}, [filteredMatches, hideNoWinner]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (tab !== "players" || !highlightId) return;
|
if (tab !== "players" || !highlightId) return;
|
||||||
@@ -371,6 +467,7 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Overview
|
Overview
|
||||||
</Link>
|
</Link>
|
||||||
|
{pageAccess.players ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "players",
|
tab: "players",
|
||||||
@@ -387,11 +484,16 @@ export function AdminDashboard({
|
|||||||
: null}
|
: null}
|
||||||
)
|
)
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{pageAccess.matches ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "matches",
|
tab: "matches",
|
||||||
highlightId,
|
highlightId,
|
||||||
participantRaw,
|
participantRaw,
|
||||||
|
matchesFrom,
|
||||||
|
matchesTo,
|
||||||
|
matchesTzOffsetMinutes,
|
||||||
})}
|
})}
|
||||||
className={tabClass(tab === "matches")}
|
className={tabClass(tab === "matches")}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
@@ -403,6 +505,27 @@ export function AdminDashboard({
|
|||||||
: null}
|
: null}
|
||||||
)
|
)
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "matchmaker",
|
tab: "matchmaker",
|
||||||
@@ -414,6 +537,8 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Matchmaker
|
Matchmaker
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{pageAccess.ledger ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildDashboardHref({
|
href={buildDashboardHref({
|
||||||
tab: "ledger",
|
tab: "ledger",
|
||||||
@@ -425,6 +550,30 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Ledger
|
Ledger
|
||||||
</Link>
|
</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
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className={tabClass(false)}
|
className={tabClass(false)}
|
||||||
@@ -432,6 +581,7 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-[1400px]">
|
<div className="mx-auto w-full max-w-[1400px]">
|
||||||
@@ -521,6 +671,7 @@ export function AdminDashboard({
|
|||||||
activeDir={lbSortDir}
|
activeDir={lbSortDir}
|
||||||
onActivate={onLbSort}
|
onActivate={onLbSort}
|
||||||
/>
|
/>
|
||||||
|
<th className="px-4 py-3 font-medium">Email</th>
|
||||||
<LeaderboardSortTh
|
<LeaderboardSortTh
|
||||||
label="RC balance"
|
label="RC balance"
|
||||||
sortKey="rc"
|
sortKey="rc"
|
||||||
@@ -550,7 +701,7 @@ export function AdminDashboard({
|
|||||||
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={5}
|
colSpan={6}
|
||||||
className="px-4 py-8 text-center text-zinc-500"
|
className="px-4 py-8 text-center text-zinc-500"
|
||||||
>
|
>
|
||||||
No wins recorded yet (no rows with a winner).
|
No wins recorded yet (no rows with a winner).
|
||||||
@@ -578,6 +729,9 @@ export function AdminDashboard({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<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)}
|
{formatRcBalanceWithCoins(row.rcBalance)}
|
||||||
</td>
|
</td>
|
||||||
@@ -605,31 +759,51 @@ export function AdminDashboard({
|
|||||||
{usersError ? (
|
{usersError ? (
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
|
<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">
|
<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">
|
<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">
|
<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>
|
<tr>
|
||||||
<th className="px-4 py-3 font-medium">ID</th>
|
<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">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">CC</th>
|
||||||
<th className="px-4 py-3 font-medium">RC</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">Created</th>
|
||||||
<th className="px-4 py-3 font-medium">Last seen</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>
|
<th className="px-4 py-3 font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||||
{users.length === 0 ? (
|
{filteredUsers.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={7}
|
colSpan={9}
|
||||||
className="px-4 py-8 text-center text-zinc-500"
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
users.map((u) => {
|
filteredUsers.map((u) => {
|
||||||
const isHi =
|
const isHi =
|
||||||
highlightId != null &&
|
highlightId != null &&
|
||||||
highlightId === String(u.id);
|
highlightId === String(u.id);
|
||||||
@@ -647,6 +821,9 @@ export function AdminDashboard({
|
|||||||
<ClickableUserId id={u.id} />
|
<ClickableUserId id={u.id} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">{u.username ?? "—"}</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.cc ?? "—"}</td>
|
||||||
<td className="px-4 py-2">{u.rc ?? "—"}</td>
|
<td className="px-4 py-2">{u.rc ?? "—"}</td>
|
||||||
<td className="px-4 py-2 whitespace-nowrap">
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
@@ -655,8 +832,12 @@ export function AdminDashboard({
|
|||||||
<td className="px-4 py-2 whitespace-nowrap">
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
{formatTs(u.last_logged_at)}
|
{formatTs(u.last_logged_at)}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canWritePlayers ? (
|
||||||
<Link
|
<Link
|
||||||
href={editHref(u)}
|
href={editHref(u)}
|
||||||
scroll={false}
|
scroll={false}
|
||||||
@@ -664,6 +845,7 @@ export function AdminDashboard({
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href={`/?tab=matches&participant=${u.id}`}
|
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"
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : tab === "matchmaker" ? (
|
) : tab === "matchmaker" ? (
|
||||||
@@ -723,6 +906,18 @@ export function AdminDashboard({
|
|||||||
errorMessage={matchmakerError}
|
errorMessage={matchmakerError}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
) : tab === "analysis" ? (
|
||||||
|
<AdminMatchAnalysis
|
||||||
|
analysis={matchAnalysis}
|
||||||
|
pingAnalytics={pingAnalytics}
|
||||||
|
geolocationAnalytics={geolocationAnalytics}
|
||||||
|
users={users}
|
||||||
|
analysisFrom={analysisFrom}
|
||||||
|
analysisTo={analysisTo}
|
||||||
|
selectedPlayerIds={analysisPlayerIds}
|
||||||
|
highlightId={highlightId}
|
||||||
|
participantRaw={participantRaw}
|
||||||
|
/>
|
||||||
) : tab === "ledger" ? (
|
) : tab === "ledger" ? (
|
||||||
<AdminLedger
|
<AdminLedger
|
||||||
ledgerGlobalSummary={ledgerGlobalSummary}
|
ledgerGlobalSummary={ledgerGlobalSummary}
|
||||||
@@ -740,9 +935,103 @@ export function AdminDashboard({
|
|||||||
participantRaw={participantRaw}
|
participantRaw={participantRaw}
|
||||||
ledgerFrom={ledgerFrom}
|
ledgerFrom={ledgerFrom}
|
||||||
ledgerTo={ledgerTo}
|
ledgerTo={ledgerTo}
|
||||||
|
supplyError={supplyError}
|
||||||
|
readOnly={!canWriteLedger}
|
||||||
/>
|
/>
|
||||||
|
) : tab === "logs" ? (
|
||||||
|
<AdminSystemLogs entries={auditEntries} error={auditError} />
|
||||||
) : (
|
) : (
|
||||||
<section className="space-y-3">
|
<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 ? (
|
{participantId != null ? (
|
||||||
<div
|
<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"
|
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})
|
{filteredMatches.length} of {matches.length})
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<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"
|
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}
|
scroll={false}
|
||||||
>
|
>
|
||||||
@@ -800,7 +1096,7 @@ export function AdminDashboard({
|
|||||||
{visibleMatches.length === 0 ? (
|
{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">
|
<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
|
{filteredMatches.length === 0
|
||||||
? "No matches yet."
|
? "No matches in this date range."
|
||||||
: hideNoWinner
|
: hideNoWinner
|
||||||
? "No matches left after hiding no-winner matches."
|
? "No matches left after hiding no-winner matches."
|
||||||
: "No matches for this filter."}
|
: "No matches for this filter."}
|
||||||
@@ -818,8 +1114,8 @@ export function AdminDashboard({
|
|||||||
key={m.id}
|
key={m.id}
|
||||||
matchId={m.id}
|
matchId={m.id}
|
||||||
statusLabel={statusLabel(m.status)}
|
statusLabel={statusLabel(m.status)}
|
||||||
createdAtLabel={formatTs(m.created_at)}
|
createdAtLabel={<LocalTimestamp value={m.created_at} />}
|
||||||
entryFeeCoins={matchEntryFeeCoinString(m.entry_fee)}
|
entryFeeCoins={m.entryFeePerPlayerCoins}
|
||||||
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
|
||||||
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
|
||||||
winnerId={m.winner_id}
|
winnerId={m.winner_id}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
export function AdminHeader() {
|
type Props = {
|
||||||
|
username?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminHeader({ username, isAdmin = false }: Props) {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
window.location.href = "/login";
|
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">
|
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
Kick Kings Admin Dashboard
|
Kick Kings Admin Dashboard
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||||
import { buildDashboardHref } from "@/lib/dashboard-search-url";
|
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 { auditMatchEconomics } from "@/lib/ledger-match-audit";
|
||||||
import {
|
import {
|
||||||
computeLedgerIntegrity,
|
computeLedgerIntegrity,
|
||||||
@@ -21,6 +24,7 @@ import {
|
|||||||
type LedgerSortOrder,
|
type LedgerSortOrder,
|
||||||
} from "@/lib/ledger-table-view";
|
} from "@/lib/ledger-table-view";
|
||||||
import { buildLedgerScopedDailyCumulativeSeries } from "@/lib/ledger-scoped-daily-series";
|
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 { LedgerRangeCumulativeChart } from "@/components/ledger-range-cumulative-chart";
|
||||||
import type { DbTransaction, DbUser } from "@/types/database";
|
import type { DbTransaction, DbUser } from "@/types/database";
|
||||||
|
|
||||||
@@ -86,6 +90,8 @@ type Props = {
|
|||||||
participantRaw: string | null;
|
participantRaw: string | null;
|
||||||
ledgerFrom: string;
|
ledgerFrom: string;
|
||||||
ledgerTo: string;
|
ledgerTo: string;
|
||||||
|
supplyError: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminLedger({
|
export function AdminLedger({
|
||||||
@@ -104,6 +110,8 @@ export function AdminLedger({
|
|||||||
participantRaw,
|
participantRaw,
|
||||||
ledgerFrom,
|
ledgerFrom,
|
||||||
ledgerTo,
|
ledgerTo,
|
||||||
|
supplyError,
|
||||||
|
readOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const usernameById = useMemo(() => {
|
const usernameById = useMemo(() => {
|
||||||
const m = new Map<number, string | null>();
|
const m = new Map<number, string | null>();
|
||||||
@@ -113,6 +121,13 @@ export function AdminLedger({
|
|||||||
return m;
|
return m;
|
||||||
}, [users]);
|
}, [users]);
|
||||||
|
|
||||||
|
const systemAccount = useMemo(() => {
|
||||||
|
for (const u of users) {
|
||||||
|
if (Number(u.id) === 1) return u;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
const { integrityGlobal, userNets } = useMemo(() => {
|
const { integrityGlobal, userNets } = useMemo(() => {
|
||||||
if (ledgerGlobalSummary == null) {
|
if (ledgerGlobalSummary == null) {
|
||||||
return {
|
return {
|
||||||
@@ -240,6 +255,25 @@ export function AdminLedger({
|
|||||||
);
|
);
|
||||||
const pageNavItems = ledgerPaginationItems(ledgerPage, ledgerTotalPages);
|
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 =
|
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";
|
"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";
|
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",
|
: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
|
<p className="text-xs font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400">
|
||||||
Σ user nets vs net issued
|
Σ user nets vs net issued
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
All transactions
|
All transactions
|
||||||
</p>
|
</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">
|
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
|
||||||
<p className="font-mono">
|
<p className="font-mono">
|
||||||
{formatBigAmount(integrityGlobal.sumUserNets)}
|
{formatBigAmount(integrityGlobal.sumUserNets)}
|
||||||
@@ -956,6 +1003,122 @@ export function AdminLedger({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</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 { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
insertSetting,
|
insertSetting,
|
||||||
|
deleteSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} from "@/app/actions/settings-actions";
|
} from "@/app/actions/settings-actions";
|
||||||
import {
|
import {
|
||||||
@@ -17,16 +18,31 @@ type Props = {
|
|||||||
rows: DbSetting[];
|
rows: DbSetting[];
|
||||||
saveError: boolean;
|
saveError: boolean;
|
||||||
addError: string | null;
|
addError: string | null;
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
|
function ReadOnlySettingRow({ row }: { row: DbSetting }) {
|
||||||
return (
|
return (
|
||||||
<form
|
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
action={updateSetting}
|
<p className="font-mono text-sm text-zinc-900 dark:text-zinc-100">
|
||||||
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
|
{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} />
|
<input type="hidden" name="key" value={row.key} />
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<label
|
<label
|
||||||
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
|
||||||
@@ -40,19 +56,37 @@ function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
|
|||||||
id={`setting-value-${index}`}
|
id={`setting-value-${index}`}
|
||||||
name="value"
|
name="value"
|
||||||
type="text"
|
type="text"
|
||||||
defaultValue={row.value ?? ""}
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
autoComplete="off"
|
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"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
disabled={saveDisabled}
|
||||||
|
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||||||
|
<input type="hidden" name="key" value={row.key} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!window.confirm(`Delete setting "${row.key}"?`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +98,8 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
|||||||
const [v, setV] = useState(initial);
|
const [v, setV] = useState(initial);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
action={updateSetting}
|
<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="key" value={row.key} />
|
||||||
<input type="hidden" name="value" value={String(v)} />
|
<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="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
@@ -108,6 +140,22 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||||||
|
<input type="hidden" name="key" value={row.key} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!window.confirm(`Delete setting "${row.key}"?`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,16 +171,20 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
|||||||
return rcToCoins(Number(t));
|
return rcToCoins(Number(t));
|
||||||
}, [rcText]);
|
}, [rcText]);
|
||||||
|
|
||||||
|
const saveDisabled = rcText.trim() === "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
<form
|
<form
|
||||||
action={updateSetting}
|
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) => {
|
onSubmit={(e) => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
const trimmed = rcText.trim();
|
const trimmed = rcText.trim();
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLocalError("Enter an RC value (one decimal place; tenths digit 0–3).");
|
setLocalError(
|
||||||
|
"Enter an RC value (one decimal place; tenths digit 0–3).",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rc = Number(trimmed);
|
const rc = Number(trimmed);
|
||||||
@@ -174,13 +226,16 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
|||||||
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
|
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 ? (
|
{localError ? (
|
||||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
|
<p
|
||||||
|
className="mt-2 text-sm text-red-600 dark:text-red-400"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{localError}
|
{localError}
|
||||||
</p>
|
</p>
|
||||||
) : coinsPreview !== null ? (
|
) : coinsPreview !== null ? (
|
||||||
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the
|
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in
|
||||||
database.
|
the database.
|
||||||
</p>
|
</p>
|
||||||
) : rcText.trim() !== "" ? (
|
) : rcText.trim() !== "" ? (
|
||||||
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||||
@@ -190,12 +245,29 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
disabled={saveDisabled}
|
||||||
|
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form action={deleteSetting} className="mt-4 flex justify-end">
|
||||||
|
<input type="hidden" name="key" value={row.key} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!window.confirm(`Delete setting "${row.key}"?`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +285,52 @@ export function AdminSettingsEditor({
|
|||||||
rows,
|
rows,
|
||||||
saveError,
|
saveError,
|
||||||
addError,
|
addError,
|
||||||
|
readOnly = false,
|
||||||
}: Props) {
|
}: 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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
||||||
{saveError ? (
|
{saveError ? (
|
||||||
@@ -255,6 +372,10 @@ export function AdminSettingsEditor({
|
|||||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||||
Enter a non-empty key.
|
Enter a non-empty key.
|
||||||
</p>
|
</p>
|
||||||
|
) : addError === "missingValue" ? (
|
||||||
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||||
|
Enter a non-empty value.
|
||||||
|
</p>
|
||||||
) : addError === "config" ? (
|
) : addError === "config" ? (
|
||||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
|
||||||
Supabase admin client is not configured.
|
Supabase admin client is not configured.
|
||||||
@@ -277,7 +398,8 @@ export function AdminSettingsEditor({
|
|||||||
id="new-setting-key"
|
id="new-setting-key"
|
||||||
name="newKey"
|
name="newKey"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
value={newKey}
|
||||||
|
onChange={(e) => setNewKey(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="e.g. maintenance_message"
|
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"
|
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"
|
id="new-setting-value"
|
||||||
name="newValue"
|
name="newValue"
|
||||||
type="text"
|
type="text"
|
||||||
|
value={newValue}
|
||||||
|
onChange={(e) => setNewValue(e.target.value)}
|
||||||
autoComplete="off"
|
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"
|
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">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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
|
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,6 +90,8 @@ export function EditUserCcRcOverlay({
|
|||||||
? "matchmaker"
|
? "matchmaker"
|
||||||
: tab === "ledger"
|
: tab === "ledger"
|
||||||
? "ledger"
|
? "ledger"
|
||||||
|
: tab === "analysis"
|
||||||
|
? "analysis"
|
||||||
: "dashboard"
|
: "dashboard"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||||
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
|
import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc";
|
||||||
|
|
||||||
type PlayerSide = {
|
/** Ledger rows are per player; both players pay → show `1.0 RC x 2 = 2.0 RC`. */
|
||||||
id: number | null;
|
function rcPerPlayerTimesTwoLine(coinStr: string | null): string {
|
||||||
username: string | null;
|
if (coinStr == null || String(coinStr).trim() === "") return "—";
|
||||||
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 "—";
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryTotalRcLabel(
|
function entryCombinedPerPlayerTimesTwoLine(
|
||||||
feeCoins: string | null,
|
feeCoins: string | null,
|
||||||
holdCoins: string | null,
|
holdCoins: string | null,
|
||||||
): string {
|
): string {
|
||||||
@@ -50,13 +30,39 @@ function entryTotalRcLabel(
|
|||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sum = BigInt(String(feeCoins).trim()) + BigInt(String(holdCoins).trim());
|
const perPlayer =
|
||||||
return formatRcLabelFromCoinsBigInt(sum);
|
BigInt(String(feeCoins).trim()) + BigInt(String(holdCoins).trim());
|
||||||
|
const bothPlayers = perPlayer * BigInt(2);
|
||||||
|
return `${formatRcLabelFromCoinsBigInt(perPlayer)} x 2 = ${formatRcLabelFromCoinsBigInt(bothPlayers)}`;
|
||||||
} catch {
|
} catch {
|
||||||
return "—";
|
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(
|
function idsMatch(
|
||||||
a: number | string | null | undefined,
|
a: number | string | null | undefined,
|
||||||
b: number | string | null | undefined,
|
b: number | string | null | undefined,
|
||||||
@@ -185,9 +191,12 @@ export function MatchHistoryBattleCard({
|
|||||||
right,
|
right,
|
||||||
winnerId,
|
winnerId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const entryRc = entryTotalRcLabel(entryFeeCoins, entryHoldPerPlayerCoins);
|
const entryLine = entryCombinedPerPlayerTimesTwoLine(
|
||||||
const entryFeeRc = rcLabelFromCoinString(entryFeeCoins);
|
entryFeeCoins,
|
||||||
const entryHoldRc = rcLabelFromCoinString(entryHoldPerPlayerCoins);
|
entryHoldPerPlayerCoins,
|
||||||
|
);
|
||||||
|
const entryHoldLine = rcPerPlayerTimesTwoLine(entryHoldPerPlayerCoins);
|
||||||
|
const entryFeeLine = rcPerPlayerTimesTwoLine(entryFeeCoins);
|
||||||
const prizeCcDisplay = prizeCcLabel.trim() === "" ? "—" : prizeCcLabel;
|
const prizeCcDisplay = prizeCcLabel.trim() === "" ? "—" : prizeCcLabel;
|
||||||
const hasWinner =
|
const hasWinner =
|
||||||
winnerId != null &&
|
winnerId != null &&
|
||||||
@@ -238,16 +247,16 @@ export function MatchHistoryBattleCard({
|
|||||||
Time {createdAtLabel}
|
Time {createdAtLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||||
Entry {entryRc}
|
Entry {entryLine}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||||
Entry hold {entryHoldRc}
|
Entry hold {entryHoldLine}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
<span className="rounded bg-zinc-900/10 px-2 py-1 dark:bg-zinc-100/10">
|
||||||
Prize {prizeCcDisplay} CC
|
Prize {prizeCcDisplay} CC
|
||||||
</span>
|
</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">
|
<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 {entryFeeRc}
|
Entry fee {entryFeeLine}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
formatRcBalanceWithCoins,
|
formatRcBalanceWithCoins,
|
||||||
formatRcLabelFromCoinsBigInt,
|
formatRcLabelFromCoinsBigInt,
|
||||||
} from "@/lib/coins-rc";
|
} from "@/lib/coins-rc";
|
||||||
|
import { countryFlagFromCode } from "@/lib/ip-geolocation";
|
||||||
import { usePlayerCard } from "@/components/player-card-context";
|
import { usePlayerCard } from "@/components/player-card-context";
|
||||||
|
|
||||||
function formatTsUtc(value: string | null): string {
|
function formatTsUtc(value: string | null): string {
|
||||||
@@ -153,6 +154,12 @@ export function PlayerCardModal() {
|
|||||||
</div>
|
</div>
|
||||||
{load.status === "ok" ? (
|
{load.status === "ok" ? (
|
||||||
<div className="mt-3 grid gap-1 text-sm text-zinc-600 dark:text-zinc-400">
|
<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">
|
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
|
||||||
<span>Account created</span>
|
<span>Account created</span>
|
||||||
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
<span className="font-mono text-zinc-800 dark:text-zinc-200">
|
||||||
@@ -165,6 +172,25 @@ export function PlayerCardModal() {
|
|||||||
{formatTsUtc(load.data.lastSeen)}
|
{formatTsUtc(load.data.lastSeen)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 type { NextResponse } from "next/server";
|
||||||
|
import { createSessionToken } from "@/lib/auth/roles";
|
||||||
import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session";
|
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://. */
|
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
|
||||||
function isHttpsRequest(request: Request): boolean {
|
function isHttpsRequest(request: Request): boolean {
|
||||||
@@ -8,10 +10,20 @@ function isHttpsRequest(request: Request): boolean {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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 === "https") return true;
|
||||||
if (xf === "http") return false;
|
if (xf === "http") return false;
|
||||||
|
const fwd = request.headers.get("forwarded");
|
||||||
|
if (fwd) {
|
||||||
|
const m = /(?:^|[;,]\s*)proto=(https?)/i.exec(fwd);
|
||||||
|
if (m?.[1] === "https") return true;
|
||||||
|
if (m?.[1] === "http") return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return getRequestOrigin(request).startsWith("https://");
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionCookieSetOptions(request: Request) {
|
export function getSessionCookieSetOptions(request: Request) {
|
||||||
@@ -35,10 +47,14 @@ export function getSessionCookieClearOptions(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Attach session cookie to a response (works reliably with redirects). */
|
/** 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(
|
res.cookies.set(
|
||||||
ADMIN_SESSION_COOKIE,
|
ADMIN_SESSION_COOKIE,
|
||||||
"1",
|
createSessionToken(username),
|
||||||
getSessionCookieSetOptions(request),
|
getSessionCookieSetOptions(request),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,3 +70,25 @@ export function formatRcBalanceWithCoins(rc: number | null): string {
|
|||||||
}
|
}
|
||||||
return `${rc.toFixed(1)} RC`;
|
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"
|
| "players"
|
||||||
| "matches"
|
| "matches"
|
||||||
| "matchmaker"
|
| "matchmaker"
|
||||||
| "ledger";
|
| "ledger"
|
||||||
|
| "analysis"
|
||||||
|
| "logs";
|
||||||
|
|
||||||
export type DashboardUrlQuery = {
|
export type DashboardUrlQuery = {
|
||||||
tab: AdminDashboardTab;
|
tab: AdminDashboardTab;
|
||||||
@@ -14,6 +16,8 @@ export type DashboardUrlQuery = {
|
|||||||
participantRaw: string | null;
|
participantRaw: string | null;
|
||||||
editId?: number | null;
|
editId?: number | null;
|
||||||
saveError?: boolean;
|
saveError?: boolean;
|
||||||
|
/** Ledger: failed system supply action */
|
||||||
|
supplyError?: boolean;
|
||||||
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
|
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
|
||||||
matchmakerSource?: MatchmakerLogSource | null;
|
matchmakerSource?: MatchmakerLogSource | null;
|
||||||
/** Ledger tab: UTC date-only bounds (`lfrom` / `lto` query params). */
|
/** Ledger tab: UTC date-only bounds (`lfrom` / `lto` query params). */
|
||||||
@@ -24,6 +28,15 @@ export type DashboardUrlQuery = {
|
|||||||
ledgerPageSize?: number | null;
|
ledgerPageSize?: number | null;
|
||||||
ledgerSort?: string | null;
|
ledgerSort?: string | null;
|
||||||
ledgerOrder?: "asc" | "desc" | 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. */
|
/** 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 === "players") p.set("tab", "players");
|
||||||
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
else if (q.tab === "matchmaker") p.set("tab", "matchmaker");
|
||||||
else if (q.tab === "ledger") p.set("tab", "ledger");
|
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.highlightId) p.set("highlight", q.highlightId);
|
||||||
if (q.participantRaw) p.set("participant", q.participantRaw);
|
if (q.participantRaw) p.set("participant", q.participantRaw);
|
||||||
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
|
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)));
|
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)) {
|
if (q.editId != null && Number.isFinite(q.editId)) {
|
||||||
p.set("edit", String(q.editId));
|
p.set("edit", String(q.editId));
|
||||||
}
|
}
|
||||||
if (q.saveError) p.set("saveError", "1");
|
if (q.saveError) p.set("saveError", "1");
|
||||||
|
if (q.supplyError) p.set("supplyErr", "1");
|
||||||
const s = p.toString();
|
const s = p.toString();
|
||||||
return s ? `/?${s}` : "/";
|
return s ? `/?${s}` : "/";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type DashboardStatsSnapshot = {
|
|||||||
export type LeaderboardRow = {
|
export type LeaderboardRow = {
|
||||||
userId: number;
|
userId: number;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
/** Matches with `winner_id` equal to this user (same as player card). */
|
/** Matches with `winner_id` equal to this user (same as player card). */
|
||||||
matchesWon: number;
|
matchesWon: number;
|
||||||
rcBalance: number | null;
|
rcBalance: number | null;
|
||||||
@@ -105,7 +106,7 @@ async function fetchTopPlayersByMatchWins(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [usersRes, ...playedRes] = await Promise.all([
|
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,
|
...playedPromises,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -114,10 +115,12 @@ async function fetchTopPlayersByMatchWins(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nameById = new Map<number, string | null>();
|
const nameById = new Map<number, string | null>();
|
||||||
|
const emailById = new Map<number, string | null>();
|
||||||
const rcById = new Map<number, number | null>();
|
const rcById = new Map<number, number | null>();
|
||||||
for (const u of usersRes.data ?? []) {
|
for (const u of usersRes.data ?? []) {
|
||||||
const uid = u.id as number;
|
const uid = u.id as number;
|
||||||
nameById.set(uid, (u.username as string | null) ?? null);
|
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;
|
const rawRc = u.rc as number | null;
|
||||||
rcById.set(
|
rcById.set(
|
||||||
uid,
|
uid,
|
||||||
@@ -146,6 +149,7 @@ async function fetchTopPlayersByMatchWins(
|
|||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
username: nameById.get(userId) ?? null,
|
username: nameById.get(userId) ?? null,
|
||||||
|
email: emailById.get(userId) ?? null,
|
||||||
matchesWon,
|
matchesWon,
|
||||||
rcBalance: rcById.get(userId) ?? null,
|
rcBalance: rcById.get(userId) ?? null,
|
||||||
winRatePercent,
|
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,36 +6,78 @@ function normRemark(r: string | null | undefined): string {
|
|||||||
return (r ?? "").toLowerCase().trim();
|
return (r ?? "").toLowerCase().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>();
|
||||||
|
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 `entry_hold` debit amount (same per player when
|
* For each match_id, average debit amount for `entry_hold` and `entry_fee`
|
||||||
* both rows exist; sum/count handles odd counts).
|
* (per player when two rows exist; sum/count handles odd counts).
|
||||||
*/
|
*/
|
||||||
export async function fetchEntryHoldPerPlayerCoinsByMatchIds(
|
export async function fetchMatchEntryHoldAndFeePerPlayerCoinsByMatchIds(
|
||||||
supabase: SupabaseClient,
|
supabase: SupabaseClient,
|
||||||
matchIds: number[],
|
matchIds: number[],
|
||||||
): Promise<Map<number, bigint>> {
|
): Promise<MatchEntryHoldAndFeePerPlayerCoins> {
|
||||||
const out = new Map<number, bigint>();
|
const empty: MatchEntryHoldAndFeePerPlayerCoins = {
|
||||||
if (matchIds.length === 0) return out;
|
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
|
const { data, error } = await supabase
|
||||||
.from("transactions")
|
.from("transactions")
|
||||||
.select("match_id, amount, remarks")
|
.select("match_id, amount, remarks")
|
||||||
.in("match_id", matchIds);
|
.in("match_id", matchIds)
|
||||||
|
.in("remarks", ["entry_hold", "entry_fee"])
|
||||||
|
.order("id", { ascending: true })
|
||||||
|
.range(offset, offset + TX_PAGE_SIZE - 1);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return out;
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sums = new Map<number, bigint>();
|
const batch = (data ?? []) as Pick<
|
||||||
const counts = new Map<number, number>();
|
|
||||||
|
|
||||||
for (const row of (data ?? []) as Pick<
|
|
||||||
DbTransaction,
|
DbTransaction,
|
||||||
"match_id" | "amount" | "remarks"
|
"match_id" | "amount" | "remarks"
|
||||||
>[]) {
|
>[];
|
||||||
if (normRemark(row.remarks) !== "entry_hold") {
|
|
||||||
continue;
|
for (const row of batch) {
|
||||||
}
|
const rmk = normRemark(row.remarks);
|
||||||
|
if (rmk !== "entry_hold" && rmk !== "entry_fee") continue;
|
||||||
const midRaw = row.match_id;
|
const midRaw = row.match_id;
|
||||||
if (midRaw == null) continue;
|
if (midRaw == null) continue;
|
||||||
const mid =
|
const mid =
|
||||||
@@ -43,14 +85,16 @@ export async function fetchEntryHoldPerPlayerCoinsByMatchIds(
|
|||||||
if (!Number.isFinite(mid)) continue;
|
if (!Number.isFinite(mid)) continue;
|
||||||
const a = txAmountToBigInt(row.amount);
|
const a = txAmountToBigInt(row.amount);
|
||||||
if (a === BigInt(0)) continue;
|
if (a === BigInt(0)) continue;
|
||||||
sums.set(mid, (sums.get(mid) ?? BigInt(0)) + a);
|
if (rmk === "entry_hold") bumpAgg(holdAgg, mid, a);
|
||||||
counts.set(mid, (counts.get(mid) ?? 0) + 1);
|
else bumpAgg(feeAgg, mid, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [mid, sum] of sums) {
|
if (batch.length < TX_PAGE_SIZE) break;
|
||||||
const c = counts.get(mid) ?? 0;
|
offset += TX_PAGE_SIZE;
|
||||||
if (c > 0) out.set(mid, sum / BigInt(c));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
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 { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { DbMatch, DbTransaction } from "@/types/database";
|
import type { DbMatch, DbTransaction } from "@/types/database";
|
||||||
import { txAmountToBigInt } from "@/lib/ledger-integrity";
|
import { txAmountToBigInt } from "@/lib/ledger-integrity";
|
||||||
|
import { countryCodeFromIp } from "@/lib/ip-geolocation";
|
||||||
|
|
||||||
const TX_PAGE = 5000;
|
const TX_PAGE = 5000;
|
||||||
/** Recent matches listed on the player card (newest first). */
|
/** Recent matches listed on the player card (newest first). */
|
||||||
@@ -22,8 +23,11 @@ export type PlayerCardMatchHistoryRow = {
|
|||||||
export type PlayerCardData = {
|
export type PlayerCardData = {
|
||||||
id: number;
|
id: number;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
lastSeen: string | null;
|
lastSeen: string | null;
|
||||||
|
lastLoggedInIp: string | null;
|
||||||
|
lastLoggedInCountryCode: string | null;
|
||||||
rcBalance: number | null;
|
rcBalance: number | null;
|
||||||
ccBalance: number | null;
|
ccBalance: number | null;
|
||||||
matchesPlayed: number;
|
matchesPlayed: number;
|
||||||
@@ -83,7 +87,7 @@ export async function fetchPlayerCardData(
|
|||||||
|
|
||||||
const { data: userRow, error: userErr } = await supabase
|
const { data: userRow, error: userErr } = await supabase
|
||||||
.from("users")
|
.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)
|
.eq("id", userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -270,8 +274,13 @@ export async function fetchPlayerCardData(
|
|||||||
data: {
|
data: {
|
||||||
id: userRow.id as number,
|
id: userRow.id as number,
|
||||||
username: (userRow.username as string | null) ?? null,
|
username: (userRow.username as string | null) ?? null,
|
||||||
|
email: (userRow.email as string | null) ?? null,
|
||||||
createdAt: (userRow.created_at as string) ?? null,
|
createdAt: (userRow.created_at as string) ?? null,
|
||||||
lastSeen: (userRow.last_logged_at as string | null) ?? 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,
|
rcBalance: (userRow.rc as number | null) ?? null,
|
||||||
ccBalance: (userRow.cc as number | null) ?? null,
|
ccBalance: (userRow.cc as number | null) ?? null,
|
||||||
matchesPlayed,
|
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;
|
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): {
|
function parseForwardedHeader(value: string | null): {
|
||||||
host?: string;
|
host?: string;
|
||||||
proto?: string;
|
proto?: string;
|
||||||
@@ -83,7 +97,9 @@ export function getRequestOrigin(request: Request): string {
|
|||||||
|
|
||||||
const xfProto = request.headers.get("x-forwarded-proto");
|
const xfProto = request.headers.get("x-forwarded-proto");
|
||||||
const protoRaw = firstUrlLine(
|
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";
|
const proto = protoRaw === "https" || protoRaw === "http" ? protoRaw : "http";
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,10 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { hasValidSessionToken } from "@/lib/auth/roles";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
import { publicRequestUrl } from "@/lib/request-public-url";
|
import { publicRequestUrl } from "@/lib/request-public-url";
|
||||||
|
|
||||||
function hasValidSession(request: NextRequest): boolean {
|
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) {
|
export function proxy(request: NextRequest) {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ export type DbUser = {
|
|||||||
id: number;
|
id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
password: string | null;
|
password: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
cc: number | null;
|
cc: number | null;
|
||||||
rc: number | null;
|
rc: number | null;
|
||||||
last_logged_at: string | null;
|
last_logged_at: string | null;
|
||||||
@@ -29,6 +31,8 @@ export type DbMatch = {
|
|||||||
export type AdminMatchRow = DbMatch & {
|
export type AdminMatchRow = DbMatch & {
|
||||||
/** Per-player `entry_hold` debit in coins (stringified bigint); null if unknown. */
|
/** Per-player `entry_hold` debit in coins (stringified bigint); null if unknown. */
|
||||||
entryHoldPerPlayerCoins: string | null;
|
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). */
|
/** Mirrors `public.settings` (key/value config rows). */
|
||||||
@@ -50,3 +54,13 @@ export type DbTransaction = {
|
|||||||
remarks: string | null;
|
remarks: string | null;
|
||||||
match_id: number | 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