added chart.js
This commit is contained in:
parent
6d187fc885
commit
bd365390d5
121
package-lock.json
generated
121
package-lock.json
generated
@ -15,7 +15,7 @@
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@preline/dropdown": "^3.0.1",
|
||||
"@preline/tooltip": "^3.0.0",
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@prisma/client": "^6.16.3",
|
||||
"chart.js": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
"csgo-sharecode": "^3.1.2",
|
||||
@ -42,7 +42,6 @@
|
||||
"postcss": "^8.5.3",
|
||||
"preline": "^3.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"undici": "^7.15.0",
|
||||
@ -61,7 +60,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"prisma": "^6.16.2",
|
||||
"prisma": "^6.16.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.4",
|
||||
@ -86,6 +85,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
@ -123,7 +123,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@ -1578,6 +1577,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
@ -1598,9 +1598,9 @@
|
||||
"license": "Licensed under MIT and Preline UI Fair Use License"
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.2.tgz",
|
||||
"integrity": "sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==",
|
||||
"version": "6.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.3.tgz",
|
||||
"integrity": "sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@ -1620,9 +1620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.2.tgz",
|
||||
"integrity": "sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==",
|
||||
"version": "6.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz",
|
||||
"integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@ -1633,53 +1633,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz",
|
||||
"integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==",
|
||||
"version": "6.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz",
|
||||
"integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.2.tgz",
|
||||
"integrity": "sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==",
|
||||
"version": "6.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz",
|
||||
"integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.16.2",
|
||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"@prisma/fetch-engine": "6.16.2",
|
||||
"@prisma/get-platform": "6.16.2"
|
||||
"@prisma/debug": "6.16.3",
|
||||
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
|
||||
"@prisma/fetch-engine": "6.16.3",
|
||||
"@prisma/get-platform": "6.16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
|
||||
"integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
|
||||
"version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz",
|
||||
"integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.2.tgz",
|
||||
"integrity": "sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==",
|
||||
"version": "6.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz",
|
||||
"integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.16.2",
|
||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"@prisma/get-platform": "6.16.2"
|
||||
"@prisma/debug": "6.16.3",
|
||||
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
|
||||
"@prisma/get-platform": "6.16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.2.tgz",
|
||||
"integrity": "sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==",
|
||||
"version": "6.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz",
|
||||
"integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.16.2"
|
||||
"@prisma/debug": "6.16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
@ -2068,7 +2068,6 @@
|
||||
"integrity": "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
@ -2086,7 +2085,6 @@
|
||||
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@ -2184,7 +2182,6 @@
|
||||
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.30.1",
|
||||
"@typescript-eslint/types": "8.30.1",
|
||||
@ -2618,7 +2615,6 @@
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -3138,7 +3134,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@ -3288,6 +3283,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@ -3428,7 +3424,6 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@ -3885,7 +3880,6 @@
|
||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -4060,7 +4054,6 @@
|
||||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.8",
|
||||
@ -5409,6 +5402,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
@ -5837,6 +5831,7 @@
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
@ -6024,7 +6019,6 @@
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.3.0.tgz",
|
||||
"integrity": "sha512-k0MgP6BsK8cZ73wRjMazl2y2UcXj49ZXLDEgx6BikWuby/CN+nh81qFFI16edgd7xYpe/jj2OZEIwCoqnzz0bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "15.3.0",
|
||||
"@swc/counter": "0.1.3",
|
||||
@ -6317,7 +6311,8 @@
|
||||
"version": "0.9.15",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
@ -6334,6 +6329,7 @@
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
@ -6462,6 +6458,7 @@
|
||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
||||
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^10.13.0 || >=12.0.0"
|
||||
}
|
||||
@ -6748,6 +6745,7 @@
|
||||
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pretty-format": "^3.8.0"
|
||||
},
|
||||
@ -6778,19 +6776,19 @@
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz",
|
||||
"integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==",
|
||||
"version": "6.16.3",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz",
|
||||
"integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.16.2",
|
||||
"@prisma/engines": "6.16.2"
|
||||
"@prisma/config": "6.16.3",
|
||||
"@prisma/engines": "6.16.3"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
@ -6904,27 +6902,15 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-chartjs-2": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
|
||||
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@ -6994,7 +6980,8 @@
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
@ -7651,8 +7638,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
||||
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.1",
|
||||
@ -7709,7 +7695,6 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -7935,7 +7920,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -8191,7 +8175,8 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@preline/dropdown": "^3.0.1",
|
||||
"@preline/tooltip": "^3.0.0",
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@prisma/client": "^6.16.3",
|
||||
"chart.js": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
"csgo-sharecode": "^3.1.2",
|
||||
@ -48,7 +48,6 @@
|
||||
"postcss": "^8.5.3",
|
||||
"preline": "^3.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"undici": "^7.15.0",
|
||||
@ -67,7 +66,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"prisma": "^6.16.2",
|
||||
"prisma": "^6.16.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.4",
|
||||
|
||||
@ -1,7 +1,21 @@
|
||||
'use client'
|
||||
// components/Chart.tsx
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
type ChartType as ChartJSType,
|
||||
type ChartData,
|
||||
type ChartOptions,
|
||||
type Plugin,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
RadialLinearScale,
|
||||
@ -13,10 +27,8 @@ import {
|
||||
Legend,
|
||||
Title,
|
||||
Filler,
|
||||
type Plugin,
|
||||
} from 'chart.js'
|
||||
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
|
||||
import { useMemo, useRef } from 'react'
|
||||
RadarController,
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
@ -26,307 +38,373 @@ ChartJS.register(
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
RadarController,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Title,
|
||||
Filler
|
||||
)
|
||||
);
|
||||
|
||||
type ChartType =
|
||||
| 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
|
||||
export type ChartType =
|
||||
| 'bar'
|
||||
| 'line'
|
||||
| 'pie'
|
||||
| 'doughnut'
|
||||
| 'radar'
|
||||
| 'polarArea'
|
||||
| 'bubble'
|
||||
| 'scatter';
|
||||
|
||||
type BaseDataset = {
|
||||
label: string
|
||||
data: number[]
|
||||
backgroundColor?: string | string[]
|
||||
borderColor?: string
|
||||
borderWidth?: number
|
||||
fill?: boolean | number | 'origin' | 'start' | 'end'
|
||||
tension?: number
|
||||
spanGaps?: boolean
|
||||
}
|
||||
export type ChartHandle = {
|
||||
chart: ChartJS | null; // <-- ungenauer, aber kompatibel
|
||||
update: (mutator?: (c: ChartJS) => void) => void;
|
||||
};
|
||||
|
||||
type ChartProps = {
|
||||
type: ChartType
|
||||
labels: string[]
|
||||
datasets: BaseDataset[]
|
||||
title?: string
|
||||
type SimpleDataset = {
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor?: string | string[];
|
||||
borderColor?: string | string[];
|
||||
borderWidth?: number;
|
||||
pointRadius?: number | number[];
|
||||
pointHoverRadius?: number | number[];
|
||||
fill?: boolean;
|
||||
};
|
||||
|
||||
/** Fixe Höhe in px ODER 'auto' (Höhe aus aspectRatio) */
|
||||
height?: number | 'auto'
|
||||
/** Wird genutzt, wenn height='auto'. Standard: 2 (Breite/Höhe = 2:1) */
|
||||
aspectRatio?: number
|
||||
/** Legende & Achsen (nicht Radar) ausblenden */
|
||||
hideLabels?: boolean
|
||||
type BaseProps<TType extends ChartJSType = ChartJSType> = {
|
||||
type: TType;
|
||||
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
data?: ChartData<TType>;
|
||||
labels?: string[];
|
||||
datasets?: SimpleDataset[];
|
||||
|
||||
/** Radar-Achse */
|
||||
radarMin?: number // default 0
|
||||
radarMax?: number // default 120 (wegen +20 Offset)
|
||||
radarStepSize?: number // default 20
|
||||
radarHideTicks?: boolean // default true
|
||||
options?: ChartOptions<TType>;
|
||||
plugins?: Plugin<TType>[];
|
||||
|
||||
/** Radar-Darstellung */
|
||||
radarFillMode?: boolean | number | 'origin' | 'start' | 'end' // default: true
|
||||
radarTension?: number // default: 0
|
||||
radarSpanGaps?: boolean // default: false
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
height?: number | string;
|
||||
redraw?: boolean;
|
||||
onReady?: (chart: ChartJS) => void;
|
||||
ariaLabel?: string;
|
||||
|
||||
/** Werte auf den Ringen +stepSize nach außen schieben (Tooltips bleiben original) */
|
||||
radarAddRingOffset?: boolean
|
||||
// Radar-Extras
|
||||
radarIcons?: string[];
|
||||
radarIconSize?: number;
|
||||
radarIconLabels?: boolean;
|
||||
radarIconLabelFont?: string;
|
||||
radarIconLabelColor?: string;
|
||||
radarIconLabelMargin?: number;
|
||||
radarHideTicks?: boolean;
|
||||
radarMax?: number;
|
||||
radarStepSize?: number;
|
||||
radarAddRingOffset?: boolean;
|
||||
};
|
||||
|
||||
/** Icons statt Text-Labels */
|
||||
radarIcons?: string[] | Record<string, string>
|
||||
radarIconSize?: number // px
|
||||
radarIconOffset?: number // Abstand über dem äußersten Ring (Skaleneinheiten)
|
||||
|
||||
/** Unter jedem Radar-Icon den Label-Text rendern */
|
||||
radarIconLabels?: boolean // default: false
|
||||
radarIconLabelFont?: string // CSS canvas font, z.B. '12px Inter, sans-serif'
|
||||
radarIconLabelColor?: string // z.B. '#fff'
|
||||
radarIconLabelMargin?: number // px Abstand unterhalb des Icons
|
||||
}
|
||||
|
||||
export default function Chart({
|
||||
type,
|
||||
labels,
|
||||
datasets,
|
||||
title,
|
||||
height = 300,
|
||||
aspectRatio = 2,
|
||||
hideLabels,
|
||||
className = '',
|
||||
style,
|
||||
|
||||
// Radar defaults: 0..120 mit 20er Ringen (0/20/40/60/80/100/120)
|
||||
radarMin = 0,
|
||||
radarMax = 120,
|
||||
radarStepSize = 20,
|
||||
radarHideTicks = true,
|
||||
|
||||
radarFillMode = true,
|
||||
radarTension = 0,
|
||||
radarSpanGaps = false,
|
||||
|
||||
radarAddRingOffset = true,
|
||||
|
||||
radarIcons,
|
||||
radarIconSize = 28,
|
||||
radarIconOffset = 6,
|
||||
|
||||
// ⬇️ neu
|
||||
radarIconLabels = false,
|
||||
radarIconLabelFont = '12px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, sans-serif',
|
||||
radarIconLabelColor = '#fff',
|
||||
radarIconLabelMargin = 4,
|
||||
}: ChartProps) {
|
||||
const isRadar = type === 'radar'
|
||||
const isAutoHeight = height === 'auto'
|
||||
|
||||
// -------- Daten vorbereiten (Radar: +stepSize verschieben; Tooltips bleiben original) --------
|
||||
const originalDatasets = datasets
|
||||
const preparedDatasets = useMemo(() => {
|
||||
if (!isRadar) return originalDatasets
|
||||
const ringOffset = radarAddRingOffset ? Math.max(1, radarStepSize) : 0
|
||||
const clamp = (v: number) => Math.min(v, radarMax)
|
||||
|
||||
return originalDatasets.map(ds => ({
|
||||
...ds,
|
||||
fill: ds.fill ?? radarFillMode,
|
||||
tension: ds.tension ?? radarTension,
|
||||
spanGaps: ds.spanGaps ?? radarSpanGaps,
|
||||
data: ds.data.map(v => clamp(v + ringOffset)),
|
||||
}))
|
||||
}, [
|
||||
originalDatasets,
|
||||
isRadar,
|
||||
radarAddRingOffset,
|
||||
radarStepSize,
|
||||
radarFillMode,
|
||||
radarTension,
|
||||
radarSpanGaps,
|
||||
radarMax,
|
||||
])
|
||||
|
||||
const data = useMemo(
|
||||
() => ({ labels, datasets: preparedDatasets }),
|
||||
[labels, preparedDatasets]
|
||||
)
|
||||
|
||||
// -------- Icon-Plugin (nur für Radar) --------
|
||||
const imageCacheRef = useRef<Map<string, HTMLImageElement>>(new Map())
|
||||
|
||||
const resolveIconUrl = (i: number, label: string): string | null => {
|
||||
if (!radarIcons) return null
|
||||
if (Array.isArray(radarIcons)) return radarIcons[i] ?? null
|
||||
return radarIcons[label] ?? null
|
||||
const imgCache = new Map<string, HTMLImageElement>();
|
||||
function getImage(src: string): HTMLImageElement {
|
||||
let img = imgCache.get(src);
|
||||
if (!img) {
|
||||
img = new Image();
|
||||
img.src = src;
|
||||
imgCache.set(src, img);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
// exakt auf Radar typisieren
|
||||
const radarIconsPlugin: Plugin<'radar'> | undefined = useMemo(() => {
|
||||
if (!isRadar || !radarIcons) return undefined
|
||||
function _Chart<TType extends ChartJSType = ChartJSType>(
|
||||
props: BaseProps<TType>,
|
||||
ref: React.Ref<ChartHandle>
|
||||
) {
|
||||
const {
|
||||
type,
|
||||
data,
|
||||
labels,
|
||||
datasets,
|
||||
options,
|
||||
plugins,
|
||||
className,
|
||||
style,
|
||||
height,
|
||||
redraw = false,
|
||||
onReady,
|
||||
ariaLabel,
|
||||
|
||||
const plugin: Plugin<'radar'> = {
|
||||
id: 'radar-icons',
|
||||
afterDraw: (chart) => {
|
||||
const scale: any = chart.scales?.r
|
||||
if (!scale) return
|
||||
const ctx = chart.ctx
|
||||
const n = (chart.data.labels ?? []).length
|
||||
if (!n) return
|
||||
radarIcons,
|
||||
radarIconSize = 40,
|
||||
radarIconLabels = false,
|
||||
radarIconLabelFont = '12px Inter, system-ui, sans-serif',
|
||||
radarIconLabelColor = '#ffffff',
|
||||
radarIconLabelMargin = 6,
|
||||
radarHideTicks = false,
|
||||
radarMax,
|
||||
radarStepSize,
|
||||
radarAddRingOffset = false,
|
||||
} = props;
|
||||
|
||||
// Zeichnen knapp außerhalb des äußersten Rings
|
||||
const radiusValue = (scale.max ?? radarMax) + radarIconOffset
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<ChartJS | null>(null); // <-- hier
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const label = String(chart.data.labels?.[i] ?? '')
|
||||
const url = resolveIconUrl(i, label)
|
||||
if (!url) continue
|
||||
const prevTypeRef = useRef<ChartJSType | null>(null);
|
||||
|
||||
let img = imageCacheRef.current.get(url)
|
||||
if (!img) {
|
||||
img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = url
|
||||
imageCacheRef.current.set(url, img)
|
||||
img.onload = () => { try { chart.draw() } catch {} }
|
||||
}
|
||||
if (!img.complete) continue
|
||||
const autoData = useMemo<ChartData<TType> | undefined>(() => {
|
||||
if (data) return data;
|
||||
if (!labels || !datasets) return undefined;
|
||||
return { labels, datasets: datasets as any } as ChartData<TType>;
|
||||
}, [data, labels, datasets]);
|
||||
|
||||
const pos = scale.getPointPositionForValue(i, radiusValue) // (index, value)
|
||||
const size = radarIconSize
|
||||
const x = pos.x - size / 2
|
||||
const y = pos.y - size / 2
|
||||
const radarScaleOpts = useMemo(() => {
|
||||
if (type !== 'radar') return undefined;
|
||||
|
||||
ctx.save()
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.35)'
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 1
|
||||
ctx.drawImage(img, x, y, size, size)
|
||||
ctx.restore()
|
||||
const gridColor = 'rgba(255,255,255,0.10)'; // sichtbarer
|
||||
const angleColor = 'rgba(255,255,255,0.12)';
|
||||
|
||||
// ⬇️ Label unter dem Icon (optional)
|
||||
if (radarIconLabels) {
|
||||
const text = label
|
||||
const tx = pos.x
|
||||
const ty = y + size + radarIconLabelMargin
|
||||
ctx.save()
|
||||
ctx.font = radarIconLabelFont
|
||||
ctx.fillStyle = radarIconLabelColor
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'top'
|
||||
|
||||
// leichte Kontur/Shadow für bessere Lesbarkeit
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.45)'
|
||||
ctx.shadowBlur = 3
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 1
|
||||
|
||||
ctx.fillText(text, tx, ty)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
return plugin
|
||||
}, [isRadar, radarIcons, radarIconOffset, radarIconSize, radarMax])
|
||||
|
||||
// -------- Optionen --------
|
||||
const options = useMemo(() => {
|
||||
const base: any = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: !isAutoHeight,
|
||||
aspectRatio: !isAutoHeight ? aspectRatio : undefined,
|
||||
layout: {
|
||||
padding: { top: 50, right: 0, bottom: 50, left: 0 },
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: any) => {
|
||||
const dsIdx = ctx.datasetIndex;
|
||||
const i = ctx.dataIndex;
|
||||
const orig = originalDatasets?.[dsIdx]?.data?.[i];
|
||||
const val = Number.isFinite(orig) ? (orig as number) : ctx.parsed?.r;
|
||||
const name = ctx.dataset?.label ?? '';
|
||||
return `${name}: ${val?.toFixed?.(1) ?? val}%`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
const ticks: any = {
|
||||
beginAtZero: true,
|
||||
showLabelBackdrop: false,
|
||||
color: 'rgba(255,255,255,0.6)', // Tick-Zahlen, falls eingeblendet
|
||||
backdropColor: 'transparent',
|
||||
...(radarHideTicks ? { display: false } : {}),
|
||||
...(typeof radarStepSize === 'number' ? { stepSize: radarStepSize } : {}),
|
||||
};
|
||||
|
||||
if (isRadar) {
|
||||
base.scales = {
|
||||
r: {
|
||||
min: radarMin,
|
||||
max: radarMax,
|
||||
ticks: {
|
||||
display: !radarHideTicks,
|
||||
stepSize: radarStepSize,
|
||||
showLabelBackdrop: false,
|
||||
backdropColor: 'transparent',
|
||||
callback: (v: number) => `${v}%`,
|
||||
},
|
||||
angleLines: { color: 'rgba(255,255,255,0.08)' },
|
||||
grid: { color: 'rgba(255,255,255,0.08)' },
|
||||
pointLabels:{ display: false },
|
||||
},
|
||||
};
|
||||
} else if (hideLabels) {
|
||||
base.scales = { x: { display: false }, y: { display: false } };
|
||||
const r: any = {
|
||||
suggestedMin: 0,
|
||||
grid: {
|
||||
color: gridColor,
|
||||
lineWidth: 1,
|
||||
},
|
||||
angleLines: {
|
||||
display: true,
|
||||
color: angleColor,
|
||||
lineWidth: 1,
|
||||
},
|
||||
ticks,
|
||||
pointLabels: { display: false }, // Icons/Labels zeichnen wir selbst
|
||||
};
|
||||
|
||||
if (typeof radarMax === 'number') {
|
||||
r.max = radarMax;
|
||||
r.suggestedMax = radarMax;
|
||||
}
|
||||
|
||||
return base;
|
||||
return { r };
|
||||
}, [type, radarHideTicks, radarStepSize, radarMax]);
|
||||
|
||||
const [radarPlugin] = useState<Plugin<'radar'>>(() => ({
|
||||
id: 'radarIconsPlugin',
|
||||
afterDatasetsDraw(chart) {
|
||||
const ctx = chart.ctx as CanvasRenderingContext2D;
|
||||
const scale: any = (chart as any).scales?.r;
|
||||
if (!scale) return;
|
||||
|
||||
const lbls = chart.data.labels as string[] | undefined;
|
||||
if (!lbls?.length) return;
|
||||
|
||||
const icons = radarIcons ?? [];
|
||||
|
||||
// --- Clip auf gesamte Canvas setzen, damit außerhalb der chartArea gemalt wird
|
||||
ctx.save();
|
||||
// einige Browser unterstützen resetTransform nicht, daher optional
|
||||
(ctx as any).resetTransform?.();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, chart.width, chart.height);
|
||||
ctx.clip();
|
||||
|
||||
// Zentrum bestimmen
|
||||
const ca = (chart as any).chartArea as { left:number; right:number; top:number; bottom:number } | undefined;
|
||||
const cx0 = scale.xCenter ?? (ca ? (ca.left + ca.right) / 2 : (chart.width as number) / 2);
|
||||
const cy0 = scale.yCenter ?? (ca ? (ca.top + ca.bottom) / 2 : (chart.height as number) / 2);
|
||||
|
||||
const half = radarIconSize / 2;
|
||||
const gap = Math.max(4, radarIconLabelMargin);
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = radarIconLabelFont;
|
||||
ctx.fillStyle = radarIconLabelColor;
|
||||
|
||||
for (let i = 0; i < lbls.length; i++) {
|
||||
// Spitze auf dieser Achse (index, value)
|
||||
const p = scale.getPointPositionForValue(i, scale.max);
|
||||
const px = p.x as number;
|
||||
const py = p.y as number;
|
||||
|
||||
// Einheitsvektor vom Zentrum zur Spitze
|
||||
const dx = px - cx0;
|
||||
const dy = py - cy0;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
|
||||
// Iconzentrum leicht NACH AUSSEN verschieben
|
||||
const cx = px + ux * (half + gap);
|
||||
const cy = py + uy * (half + gap);
|
||||
|
||||
// Icon
|
||||
const src = icons[i];
|
||||
if (src) {
|
||||
const img = getImage(src);
|
||||
if (img.complete) {
|
||||
ctx.drawImage(img, cx - half, cy - half, radarIconSize, radarIconSize);
|
||||
} else {
|
||||
img.onload = () => chart.draw();
|
||||
}
|
||||
}
|
||||
|
||||
// Label unter dem Icon (screen-vertikal)
|
||||
if (radarIconLabels) {
|
||||
ctx.fillText(String(lbls[i] ?? ''), cx, cy + half + radarIconLabelMargin);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore(); // Clip/State wiederherstellen
|
||||
},
|
||||
}));
|
||||
|
||||
const mergedOptions = useMemo<ChartOptions<TType>>(() => {
|
||||
const base = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: { duration: 250 },
|
||||
} as const;
|
||||
|
||||
// Start: base -> user options
|
||||
const o: ChartOptions<TType> = {
|
||||
...(base as unknown as ChartOptions<TType>),
|
||||
...(options as ChartOptions<TType>),
|
||||
};
|
||||
|
||||
if (type === 'radar') {
|
||||
// Scales + Plugins zusammenführen
|
||||
(o as any).scales = {
|
||||
...(radarScaleOpts ?? {}),
|
||||
...(options?.scales as any),
|
||||
};
|
||||
(o as any).plugins = {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
...(options?.plugins ?? {}),
|
||||
};
|
||||
|
||||
// --- Layout-Padding für Icons/Labels am äußeren Ring --------------------
|
||||
// Font-Px aus z.B. "12px Inter, system-ui, sans-serif" extrahieren
|
||||
const fontPx = (() => {
|
||||
const m = /(\d+(?:\.\d+)?)px/i.exec(radarIconLabelFont ?? '');
|
||||
return m ? parseFloat(m[1]) : 12;
|
||||
})();
|
||||
|
||||
// Platzbedarf: Icon + (optional) Labelhöhe + kleiner Sicherheitsrand
|
||||
const pad = Math.round(
|
||||
(radarIconSize ?? 40) +
|
||||
(radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) +
|
||||
6
|
||||
);
|
||||
|
||||
const currentPadding = (o as any).layout?.padding ?? {};
|
||||
(o as any).layout = {
|
||||
...(o as any).layout,
|
||||
padding: {
|
||||
top: Math.max(pad, currentPadding.top ?? 0),
|
||||
right: Math.max(pad, currentPadding.right ?? 0),
|
||||
bottom: Math.max(pad, currentPadding.bottom ?? 0),
|
||||
left: Math.max(pad, currentPadding.left ?? 0),
|
||||
},
|
||||
};
|
||||
// ------------------------------------------------------------------------
|
||||
} else if (options?.scales) {
|
||||
(o as any).scales = {
|
||||
...(options.scales as any),
|
||||
};
|
||||
}
|
||||
|
||||
return o;
|
||||
}, [
|
||||
title, hideLabels, isAutoHeight, aspectRatio, isRadar,
|
||||
radarMin, radarMax, radarStepSize, radarHideTicks,
|
||||
originalDatasets, radarIconSize
|
||||
type,
|
||||
options,
|
||||
radarScaleOpts,
|
||||
radarIconSize,
|
||||
radarIconLabels,
|
||||
radarIconLabelFont,
|
||||
radarIconLabelMargin,
|
||||
]);
|
||||
|
||||
// -------- Render (typsicher) --------
|
||||
const wrapperStyle: React.CSSProperties = isAutoHeight
|
||||
? { width: '100%', height: '100%', position: 'relative', ...style }
|
||||
: { width: '100%', height: typeof height === 'number' ? height : undefined, ...style }
|
||||
if (isRadar) {
|
||||
// Nur hier das streng typisierte Radar-Plugin übergeben
|
||||
const radarPlugins = radarIconsPlugin ? [radarIconsPlugin] as Plugin<'radar'>[] : undefined
|
||||
return (
|
||||
<div className={className} style={wrapperStyle}>
|
||||
<Radar
|
||||
data={data}
|
||||
options={options}
|
||||
plugins={radarPlugins}
|
||||
style={{ height: '100%', width: '100%' }} // <— Canvas füllt Parent
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const mergedPlugins = useMemo<Plugin<TType>[]>(() => {
|
||||
const list: Plugin<TType>[] = [];
|
||||
if (plugins?.length) list.push(...plugins);
|
||||
if (type === 'radar') list.push(radarPlugin as any);
|
||||
return list;
|
||||
}, [plugins, type, radarPlugin]);
|
||||
|
||||
// andere Chart-Typen ohne Radar-Plugin
|
||||
const chartMap = {
|
||||
line: Line,
|
||||
bar: Bar,
|
||||
doughnut: Doughnut,
|
||||
polararea: PolarArea,
|
||||
bubble: Bubble,
|
||||
pie: Pie,
|
||||
scatter: Scatter,
|
||||
} as const
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
type,
|
||||
data: (autoData ?? { labels: [], datasets: [] }) as ChartData<TType>,
|
||||
options: mergedOptions,
|
||||
plugins: mergedPlugins,
|
||||
}),
|
||||
[type, autoData, mergedOptions, mergedPlugins]
|
||||
);
|
||||
|
||||
const NonRadar = chartMap[type as Exclude<ChartType, 'radar'>]
|
||||
useEffect(() => {
|
||||
const mustRecreate =
|
||||
redraw || !chartRef.current || prevTypeRef.current !== type;
|
||||
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
if (mustRecreate) {
|
||||
chartRef.current?.destroy();
|
||||
// ⬇️ Cast auf any, um die extrem strengen Generics in Chart.js zu umgehen
|
||||
chartRef.current = new ChartJS(canvasRef.current, config as any);
|
||||
prevTypeRef.current = type;
|
||||
onReady?.(chartRef.current);
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config, type, redraw, onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const c = chartRef.current;
|
||||
if (!c || redraw || prevTypeRef.current !== type) return;
|
||||
(c as any).data = (autoData ?? c.data);
|
||||
(c as any).options = mergedOptions;
|
||||
c.update();
|
||||
}, [autoData, mergedOptions, type, redraw]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ChartHandle => ({
|
||||
chart: chartRef.current,
|
||||
update: (mutator) => {
|
||||
const c = chartRef.current;
|
||||
if (!c) return;
|
||||
mutator?.(c);
|
||||
c.update();
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
...(height && height !== 'auto'
|
||||
? { height: typeof height === 'number' ? `${height}px` : String(height) }
|
||||
: {}),
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} style={wrapperStyle}>
|
||||
<NonRadar
|
||||
data={data}
|
||||
options={options}
|
||||
style={{ height: '100%', width: '100%' }} // <— auch für andere Typen
|
||||
/>
|
||||
<canvas ref={canvasRef} role="img" aria-label={ariaLabel ?? 'Chart'} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const Chart = forwardRef(_Chart) as <TType extends ChartJSType = ChartJSType>(
|
||||
p: BaseProps<TType> & { ref?: React.Ref<ChartHandle> }
|
||||
) => ReturnType<typeof _Chart>;
|
||||
|
||||
export default Chart;
|
||||
|
||||
@ -197,6 +197,10 @@ export default function CommunityMatchList({ matchType }: Props) {
|
||||
const defaults = getNextHourDefaults()
|
||||
const [matchDateStr, setMatchDateStr] = useState<string>(defaults.dateStr) // YYYY-MM-DD
|
||||
const [matchTimeStr, setMatchTimeStr] = useState<string>(defaults.timeStr) // HH:MM
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const quarters = [0, 15, 30, 45];
|
||||
|
||||
const teamById = useCallback(
|
||||
(id?: string) => teams.find(t => t.id === id),
|
||||
@ -562,7 +566,6 @@ export default function CommunityMatchList({ matchType }: Props) {
|
||||
onSave={handleCreate}
|
||||
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
|
||||
closeButtonColor="blue"
|
||||
hideCloseButton={!canSave}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Team A */}
|
||||
@ -618,31 +621,55 @@ export default function CommunityMatchList({ matchType }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Datum & Uhrzeit */}
|
||||
<div className="grid grid-cols-2 gap-3 mt-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={matchDateStr}
|
||||
min={new Date().toISOString().slice(0,10)}
|
||||
onChange={(e) => setMatchDateStr(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Uhrzeit</label>
|
||||
<input
|
||||
type="time"
|
||||
value={matchTimeStr}
|
||||
onChange={(e) => setMatchTimeStr(e.target.value)}
|
||||
step={300} // 5-Minuten-Schritte
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
|
||||
{/* Links: Datum */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={matchDateStr}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(e) => setMatchDateStr(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rechts: Uhrzeit (HH + MM in Vierteln) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Uhrzeit</label>
|
||||
<div className="flex gap-2">
|
||||
{/* Stunde */}
|
||||
<select
|
||||
value={matchTimeStr.split(':')[0]}
|
||||
onChange={(e) => {
|
||||
const [, mm] = matchTimeStr.split(':');
|
||||
setMatchTimeStr(`${pad(Number(e.target.value))}:${mm}`);
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||||
>
|
||||
{hours.map(h => <option key={h} value={pad(h)}>{pad(h)}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Minuten (00/15/30/45) */}
|
||||
<select
|
||||
value={pad(Math.round(Number(matchTimeStr.split(':')[1]) / 15) * 15 % 60)}
|
||||
onChange={(e) => {
|
||||
const [hh] = matchTimeStr.split(':');
|
||||
setMatchTimeStr(`${hh}:${pad(Number(e.target.value))}`);
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||||
>
|
||||
{quarters.map(q => <option key={q} value={pad(q)}>{pad(q)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500 dark:text-neutral-400 mt-1">
|
||||
Datum & Uhrzeit werden als lokale Zeit gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 dark:text-neutral-400">
|
||||
Die Uhrzeit wird als lokale Zeit gespeichert.
|
||||
</p>
|
||||
|
||||
{/* Best-of */}
|
||||
<div className="mt-3">
|
||||
|
||||
@ -9,6 +9,10 @@ import LoadingSpinner from '../components/LoadingSpinner' // ⬅️ NEU
|
||||
|
||||
type TeamOption = { id: string; name: string; logo?: string | null }
|
||||
|
||||
type ZonedParts = {
|
||||
year: number; month: number; day: number; hour: number; minute: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
@ -25,6 +29,45 @@ type Props = {
|
||||
defaultBestOf?: 3 | 5
|
||||
}
|
||||
|
||||
function getUserTimeZone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin';
|
||||
}
|
||||
|
||||
function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const parts = new Intl.DateTimeFormat(locale, {
|
||||
timeZone,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(d);
|
||||
|
||||
const g = (t: string) => Number(parts.find(p => p.type === t)?.value ?? '0');
|
||||
return { year: g('year'), month: g('month'), day: g('day'), hour: g('hour'), minute: g('minute') };
|
||||
}
|
||||
|
||||
/** ISO -> lokale yyyy-mm-dd / HH:MM (in user TZ) */
|
||||
function isoToLocalDateTimeStrings(iso?: string | null, tz = getUserTimeZone()) {
|
||||
if (!iso) {
|
||||
const now = new Date();
|
||||
now.setMinutes(0,0,0);
|
||||
now.setHours(now.getHours() + 1);
|
||||
const p = getZonedParts(now, tz);
|
||||
const pad = (n:number)=>String(n).padStart(2,'0');
|
||||
return { dateStr: `${p.year}-${pad(p.month)}-${pad(p.day)}`, timeStr: `${pad(p.hour)}:${pad(p.minute)}` };
|
||||
}
|
||||
const p = getZonedParts(iso, tz);
|
||||
const pad = (n:number)=>String(n).padStart(2,'0');
|
||||
return { dateStr: `${p.year}-${pad(p.month)}-${pad(p.day)}`, timeStr: `${pad(p.hour)}:${pad(p.minute)}` };
|
||||
}
|
||||
|
||||
/** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */
|
||||
function combineLocalDateTime(dateStr: string, timeStr: string) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const [hh, mm] = timeStr.split(':').map(Number);
|
||||
const dt = new Date(y, (m - 1), d, hh, mm, 0, 0); // lokale Zeit
|
||||
return dt.toISOString();
|
||||
}
|
||||
|
||||
export default function EditMatchMetaModal({
|
||||
show,
|
||||
onClose,
|
||||
@ -56,7 +99,19 @@ export default function EditMatchMetaModal({
|
||||
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
||||
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
||||
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
|
||||
const [date, setDate] = useState<string>(toDatetimeLocal(defaultDateISO))
|
||||
|
||||
const userTZ = getUserTimeZone();
|
||||
const initDT = isoToLocalDateTimeStrings(defaultDateISO, userTZ);
|
||||
const [matchDateStr, setMatchDateStr] = useState<string>(initDT.dateStr); // YYYY-MM-DD
|
||||
const [matchTimeStr, setMatchTimeStr] = useState<string>(initDT.timeStr); // HH:MM
|
||||
const pad2 = (n:number)=>String(n).padStart(2,'0');
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const quarters = [0, 15, 30, 45];
|
||||
|
||||
// Map-Vote öffnet: Datum & Uhrzeit (lokal)
|
||||
const [voteOpenDateStr, setVoteOpenDateStr] = useState<string>(''); // YYYY-MM-DD
|
||||
const [voteOpenTimeStr, setVoteOpenTimeStr] = useState<string>(''); // HH:MM
|
||||
|
||||
const [bestOf, setBestOf] = useState<3 | 5>(normalizeBestOf(defaultBestOf))
|
||||
|
||||
const [teams, setTeams] = useState<TeamOption[]>([])
|
||||
@ -139,10 +194,20 @@ export default function EditMatchMetaModal({
|
||||
setTitle(j?.title ?? '')
|
||||
setTeamAId(j?.teamAId ?? '')
|
||||
setTeamBId(j?.teamBId ?? '')
|
||||
setDate(toDatetimeLocal(j?.matchDate ?? j?.demoDate ?? null))
|
||||
setVoteLead(
|
||||
Number.isFinite(Number(j?.mapVote?.leadMinutes)) ? Number(j.mapVote.leadMinutes) : 60
|
||||
)
|
||||
const dt = isoToLocalDateTimeStrings(j?.matchDate ?? j?.demoDate ?? null, userTZ);
|
||||
setMatchDateStr(dt.dateStr);
|
||||
setMatchTimeStr(dt.timeStr);
|
||||
const leadMin = Number.isFinite(Number(j?.mapVote?.leadMinutes))
|
||||
? Number(j.mapVote.leadMinutes)
|
||||
: 60;
|
||||
setVoteLead(leadMin);
|
||||
|
||||
// Vote-Open = MatchStart - leadMin
|
||||
const matchISO = combineLocalDateTime(dt.dateStr, dt.timeStr);
|
||||
const openISO = new Date(new Date(matchISO).getTime() - leadMin * 60_000).toISOString();
|
||||
const openDT = isoToLocalDateTimeStrings(openISO, userTZ);
|
||||
setVoteOpenDateStr(openDT.dateStr);
|
||||
setVoteOpenTimeStr(openDT.timeStr);
|
||||
|
||||
const boFromMeta = normalizeBestOf(j?.bestOf)
|
||||
setBestOf(boFromMeta)
|
||||
@ -182,22 +247,41 @@ export default function EditMatchMetaModal({
|
||||
/* ───────── Validation ───────── */
|
||||
const canSave = useMemo(() => {
|
||||
if (saving || loadingMeta) return false
|
||||
if (!date) return false
|
||||
if (!matchDateStr || !matchTimeStr) return false
|
||||
if (!voteOpenDateStr || !voteOpenTimeStr) return false
|
||||
|
||||
// Vote-Open darf nicht NACH Matchstart liegen
|
||||
const matchISO = combineLocalDateTime(matchDateStr, matchTimeStr)
|
||||
const openISO = combineLocalDateTime(voteOpenDateStr, voteOpenTimeStr)
|
||||
if (new Date(openISO).getTime() > new Date(matchISO).getTime()) return false
|
||||
if (teamAId && teamBId && teamAId === teamBId) return false
|
||||
return true
|
||||
}, [saving, loadingMeta, date, teamAId, teamBId])
|
||||
}, [saving, loadingMeta, teamAId, teamBId])
|
||||
|
||||
/* ───────── Save ───────── */
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const matchISO = (matchDateStr && matchTimeStr)
|
||||
? combineLocalDateTime(matchDateStr, matchTimeStr)
|
||||
: null;
|
||||
|
||||
const voteOpenISO = (voteOpenDateStr && voteOpenTimeStr)
|
||||
? combineLocalDateTime(voteOpenDateStr, voteOpenTimeStr)
|
||||
: null;
|
||||
|
||||
const leadMinutes =
|
||||
matchISO && voteOpenISO
|
||||
? Math.max(0, Math.round((new Date(matchISO).getTime() - new Date(voteOpenISO).getTime()) / 60000))
|
||||
: 60;
|
||||
|
||||
const body = {
|
||||
title: title || null,
|
||||
teamAId: teamAId || null,
|
||||
teamBId: teamBId || null,
|
||||
matchDate: date ? new Date(date).toISOString() : null,
|
||||
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
|
||||
matchDate: (matchDateStr && matchTimeStr) ? combineLocalDateTime(matchDateStr, matchTimeStr) : null,
|
||||
voteLeadMinutes: leadMinutes,
|
||||
bestOf,
|
||||
}
|
||||
|
||||
@ -292,36 +376,116 @@ export default function EditMatchMetaModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Datum/Uhrzeit */}
|
||||
{/* Datum & Uhrzeit (wie in CommunityMatchList) */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Datum & Uhrzeit</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}).
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
|
||||
{/* Datum */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={matchDateStr}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(e) => setMatchDateStr(e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Uhrzeit: HH + Viertelminuten */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Uhrzeit</label>
|
||||
<div className="flex gap-2">
|
||||
{/* Stunden */}
|
||||
<select
|
||||
value={matchTimeStr.split(':')[0]}
|
||||
onChange={(e) => {
|
||||
const [, mm] = matchTimeStr.split(':');
|
||||
setMatchTimeStr(`${pad2(Number(e.target.value))}:${mm}`);
|
||||
}}
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
disabled={loadingMeta}
|
||||
>
|
||||
{hours.map(h => <option key={h} value={pad2(h)}>{pad2(h)}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Minuten: 00/15/30/45 */}
|
||||
<select
|
||||
value={pad2(Math.round(Number(matchTimeStr.split(':')[1]) / 15) * 15 % 60)}
|
||||
onChange={(e) => {
|
||||
const [hh] = matchTimeStr.split(':');
|
||||
setMatchTimeStr(`${hh}:${pad2(Number(e.target.value))}`);
|
||||
}}
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
disabled={loadingMeta}
|
||||
>
|
||||
{quarters.map(q => <option key={q} value={pad2(q)}>{pad2(q)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map-Vote öffnet (Datum & Uhrzeit) */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Map-Vote öffnet</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
|
||||
{/* Datum */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={voteOpenDateStr}
|
||||
onChange={(e) => setVoteOpenDateStr(e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
</div>
|
||||
{/* Uhrzeit */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Uhrzeit</label>
|
||||
<div className="flex gap-2">
|
||||
{/* Stunden */}
|
||||
<select
|
||||
value={voteOpenTimeStr.split(':')[0] || '00'}
|
||||
onChange={(e) => {
|
||||
const [, mm] = (voteOpenTimeStr || '00:00').split(':');
|
||||
setVoteOpenTimeStr(`${pad2(Number(e.target.value))}:${mm}`);
|
||||
}}
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
disabled={loadingMeta}
|
||||
>
|
||||
{hours.map(h => <option key={h} value={pad2(h)}>{pad2(h)}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Minuten: 00/15/30/45 */}
|
||||
<select
|
||||
value={pad2(Math.round(Number((voteOpenTimeStr.split(':')[1] || '0')) / 15) * 15 % 60)}
|
||||
onChange={(e) => {
|
||||
const [hh] = (voteOpenTimeStr || '00:00').split(':');
|
||||
setVoteOpenTimeStr(`${hh}:${pad2(Number(e.target.value))}`);
|
||||
}}
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
disabled={loadingMeta}
|
||||
>
|
||||
{[0,15,30,45].map(q => <option key={q} value={pad2(q)}>{pad2(q)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Beim Speichern wird der Lead automatisch aus <em>Matchstart</em> minus <em>Vote-Start</em> berechnet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vote-Lead */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Map-Vote lead (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={voteLead}
|
||||
onChange={(e) => setVoteLead(Number(e.target.value))}
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
|
||||
</p>
|
||||
</div>
|
||||
{(voteOpenDateStr && voteOpenTimeStr && matchDateStr && matchTimeStr) &&
|
||||
new Date(combineLocalDateTime(voteOpenDateStr, voteOpenTimeStr)).getTime() >
|
||||
new Date(combineLocalDateTime(matchDateStr, matchTimeStr)).getTime() && (
|
||||
<Alert type="soft" color="warning" className="col-span-2">
|
||||
Der Map-Vote darf nicht <b>nach</b> dem Matchstart liegen.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Best Of */}
|
||||
<div className="col-span-2">
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
------------------------------------------------------------------- */
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import {
|
||||
DndContext, closestCenter, DragOverlay,
|
||||
@ -69,6 +69,9 @@ export default function EditMatchPlayersModal (props: Props) {
|
||||
const otherInit = side === 'A' ? initialB : initialA
|
||||
const myInit = side === 'A' ? initialA : initialB
|
||||
|
||||
// 🔧 NEU: schnelles Lookup der "verbotenen" Spieler (bereits im anderen Team)
|
||||
const otherInitSet = useMemo(() => new Set(otherInit), [otherInit])
|
||||
|
||||
/* ---- Komplett-Spielerliste laden ------------------------ */
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
@ -97,18 +100,20 @@ export default function EditMatchPlayersModal (props: Props) {
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
// 👉 Hier brauchst du KEIN Normalizer mehr, wenn deine /api/team-Route
|
||||
// (wie zuletzt angepasst) bereits Player-Objekte liefert.
|
||||
const all = [
|
||||
const allRaw: Player[] = [
|
||||
...(data.activePlayers ?? []),
|
||||
...(data.inactivePlayers ?? []),
|
||||
]
|
||||
.filter((p: Player) => !!p?.steamId)
|
||||
.filter((p: Player, i: number, arr: Player[]) => arr.findIndex(x => x.steamId === p.steamId) === i)
|
||||
|
||||
// 🔧 NEU: Spieler entfernen, die im anderen Team bereits gesetzt sind
|
||||
const all = allRaw
|
||||
.filter((p: Player) => !otherInitSet.has(p.steamId))
|
||||
.sort((a: Player, b: Player) => (a.name || '').localeCompare(b.name || ''))
|
||||
|
||||
setPlayers(all)
|
||||
setSelected(myInit) // initiale Auswahl aus Props
|
||||
setSelected(myInit.filter(id => !otherInitSet.has(id)))
|
||||
setSaved(false)
|
||||
} catch (e) {
|
||||
console.error('[EditMatchPlayersModal] load error:', e)
|
||||
|
||||
@ -73,24 +73,45 @@ export default function GameBanner(props: Props) {
|
||||
const tGameBanner = useTranslations('game-banner')
|
||||
|
||||
const phaseStr = String(phase ?? 'unknown').toLowerCase()
|
||||
const show = !isSmDown && visible && phaseStr !== 'unknown'
|
||||
|
||||
// <-- Hook wird IMMER aufgerufen, macht aber nichts wenn !show
|
||||
// Ziel-Sichtbarkeit anhand Props/Viewport/Phase
|
||||
const targetShow = !isSmDown && visible && phaseStr !== 'unknown'
|
||||
|
||||
// Animations-Dauer (muss zu den CSS-Klassen passen)
|
||||
const ANIM_MS = 250
|
||||
|
||||
// "rendered": bleibt true, bis die Exit-Animation fertig ist
|
||||
const [rendered, setRendered] = useState<boolean>(targetShow)
|
||||
// anim: 'in' = sichtbar-Animation, 'out' = verstecken-Animation
|
||||
const [anim, setAnim] = useState<'in' | 'out'>(targetShow ? 'in' : 'out')
|
||||
|
||||
// Mount/Unmount steuern anhand targetShow
|
||||
useEffect(() => {
|
||||
if (targetShow) {
|
||||
// Mounten + Startzustand unten -> dann hochfahren
|
||||
setRendered(true)
|
||||
setAnim('out')
|
||||
requestAnimationFrame(() => setAnim('in'))
|
||||
} else {
|
||||
// Runterfahren -> danach unmounten
|
||||
setAnim('out')
|
||||
const t = setTimeout(() => setRendered(false), ANIM_MS)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [targetShow])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!show || !el) { // guard
|
||||
setBannerPx(0)
|
||||
return
|
||||
}
|
||||
if (!rendered || !el) { setBannerPx(0); return }
|
||||
const report = () => setBannerPx(el.getBoundingClientRect().height)
|
||||
report()
|
||||
const ro = new ResizeObserver(report)
|
||||
ro.observe(el)
|
||||
return () => { ro.disconnect(); setBannerPx(0) }
|
||||
}, [show, setBannerPx])
|
||||
}, [rendered, setBannerPx])
|
||||
|
||||
// ab hier darfst du bedingt rendern
|
||||
if (!show) return null
|
||||
if (!rendered) return null
|
||||
|
||||
const outerBase = inline ? '' : 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
|
||||
const outerStyle = inline ? undefined : ({ zIndex } as React.CSSProperties)
|
||||
@ -122,9 +143,14 @@ export default function GameBanner(props: Props) {
|
||||
</div>
|
||||
)
|
||||
|
||||
const animBase = 'transition-[transform,opacity] duration-250 ease-out will-change-[transform,opacity]'
|
||||
const animClass = anim === 'in'
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 translate-y-full'
|
||||
|
||||
return (
|
||||
<div className={outerBase} style={outerStyle} ref={ref}>
|
||||
<div className={`relative overflow-hidden shadow-lg ${wrapperClass} transition duration-300 ease-in-out`}>
|
||||
<div className={`relative overflow-hidden shadow-lg ${wrapperClass} ${animBase} ${animClass}`}>
|
||||
{/* Hintergrundbild (Map) */}
|
||||
{bgUrl && (
|
||||
<div
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// InvitePlayersModal.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, useTransition } from 'react'
|
||||
@ -61,6 +63,8 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const firstCardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const showControls = !isSuccess
|
||||
|
||||
// aktuelle Grid-Höhe halten
|
||||
const [gridHoldHeight, setGridHoldHeight] = useState<number>(0)
|
||||
|
||||
@ -211,7 +215,14 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
try { json = await res.clone().json() } catch {}
|
||||
|
||||
let results: { steamId: string; ok: boolean }[] = []
|
||||
if (json?.results && Array.isArray(json.results)) {
|
||||
|
||||
if (directAdd) {
|
||||
if (json?.results && Array.isArray(json.results)) {
|
||||
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
|
||||
} else {
|
||||
results = ids.map(id => ({ steamId: id, ok: res.ok }))
|
||||
}
|
||||
} else if (json?.results && Array.isArray(json.results)) {
|
||||
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
|
||||
} else if (Array.isArray(json?.invitationIds)) {
|
||||
const okSet = new Set<string>(json.invitationIds)
|
||||
@ -245,29 +256,20 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return
|
||||
if (sentCount > 0) {
|
||||
const t = setTimeout(() => {
|
||||
const modalEl = document.getElementById('invite-members-modal')
|
||||
if (modalEl && (window as any).HSOverlay?.close) (window as any).HSOverlay.close(modalEl)
|
||||
onClose()
|
||||
}, 2000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [isSuccess, sentCount, onClose])
|
||||
|
||||
useEffect(() => { setCurrentPage(1) }, [searchTerm])
|
||||
|
||||
const filteredUsers = useMemo(
|
||||
() => allUsers.filter(u => u.name?.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
[allUsers, searchTerm]
|
||||
)
|
||||
const filteredUsers = useMemo(() => {
|
||||
if (isSuccess) return allUsers
|
||||
return allUsers.filter(u => u.name?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
}, [allUsers, searchTerm, isSuccess])
|
||||
|
||||
const unselectedUsers = filteredUsers.filter(user =>
|
||||
!selectedIds.includes(user.steamId) &&
|
||||
(!isSuccess || !invitedIds.includes(user.steamId))
|
||||
)
|
||||
const unselectedUsers = useMemo(() => {
|
||||
if (isSuccess) return filteredUsers
|
||||
return filteredUsers.filter(user =>
|
||||
!selectedIds.includes(user.steamId) &&
|
||||
(!isSuccess || !invitedIds.includes(user.steamId))
|
||||
)
|
||||
}, [filteredUsers, selectedIds, invitedIds, isSuccess])
|
||||
|
||||
const totalPages = Math.ceil(unselectedUsers.length / Math.max(1, usersPerPage))
|
||||
const startIdx = (currentPage - 1) * Math.max(1, usersPerPage)
|
||||
@ -353,36 +355,37 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
||||
closeButtonLoading={!isSuccess && isInviting}
|
||||
scrollBody
|
||||
>
|
||||
{showControls && (
|
||||
<p ref={descRef} className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
|
||||
{directAdd
|
||||
? 'Wähle Spieler aus, die du direkt zum Team hinzufügen möchtest:'
|
||||
: 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Filterleiste */}
|
||||
<div ref={searchRef} className="mt-1 grid grid-cols-[auto_1fr] items-center gap-x-3">
|
||||
{/* kompakteres Suchfeld */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-40 sm:w-56 rounded border px-2 py-1.5 text-[13px]
|
||||
focus:outline-none focus:ring focus:ring-blue-400
|
||||
dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
|
||||
/>
|
||||
|
||||
{/* Toggle bekommt die breite zweite Spalte */}
|
||||
<div className="justify-self-end min-w-[240px] sm:min-w-[300px]">
|
||||
<Switch
|
||||
id="only-free-switch"
|
||||
checked={onlyFree}
|
||||
onChange={(v) => startTransition(() => setOnlyFree(v))}
|
||||
labelLeft="Alle"
|
||||
labelRight="Nur ohne Team"
|
||||
{showControls && (
|
||||
<div ref={searchRef} className="mt-1 grid grid-cols-[auto_1fr] items-center gap-x-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-40 sm:w-56 rounded border px-2 py-1.5 text-[13px]
|
||||
focus:outline-none focus:ring focus:ring-blue-400
|
||||
dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
|
||||
/>
|
||||
<div className="justify-self-end min-w-[240px] sm:min-w-[300px]">
|
||||
<Switch
|
||||
id="only-free-switch"
|
||||
checked={onlyFree}
|
||||
onChange={(v) => startTransition(() => setOnlyFree(v))}
|
||||
labelLeft="Alle"
|
||||
labelRight="Nur ohne Team"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ausgewählte */}
|
||||
{selectedIds.length > 0 && (
|
||||
|
||||
@ -39,6 +39,111 @@ const fmtCountdown = (ms: number) => {
|
||||
return `${h}:${pad(m)}:${pad(s)}`
|
||||
}
|
||||
|
||||
// --- Winrate-Fetch & Normalizer ---------------------------------------------
|
||||
|
||||
// einfacher In-Memory-Cache pro Spieler
|
||||
const winrateCache = new Map<string, Record<string, number>>();
|
||||
|
||||
/**
|
||||
* Normalisiert verschiedene mögliche API-Response-Shapes auf:
|
||||
* { "de_inferno": 53.2, "de_mirage": 47.8, ... } (Prozent 0..100)
|
||||
*/
|
||||
function normalizeWinrateResponse(
|
||||
raw: any,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
|
||||
if (!raw) return out;
|
||||
|
||||
// 1) Bereits im Zielformat? -> { de_inferno: 53.2, ... }
|
||||
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const key = String(k).toLowerCase();
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[key] = v;
|
||||
} else if (v && typeof v === 'object') {
|
||||
// { winrate: 53.2 } oder { wins, losses }
|
||||
const winrate = (v as any).winrate;
|
||||
const wins = Number((v as any).wins ?? (v as any).w);
|
||||
const losses = Number((v as any).losses ?? (v as any).l);
|
||||
if (typeof winrate === 'number' && Number.isFinite(winrate)) {
|
||||
out[key] = winrate;
|
||||
} else if (Number.isFinite(wins) && Number.isFinite(losses) && wins + losses > 0) {
|
||||
out[key] = (wins / (wins + losses)) * 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 2) Array-Shapes:
|
||||
// a) [{ mapKey: "de_inferno", winrate: 51.2 }, ...]
|
||||
// b) [{ map: "de_inferno", wins: 23, losses: 19 }, ...]
|
||||
if (Array.isArray(raw)) {
|
||||
for (const row of raw) {
|
||||
if (!row) continue;
|
||||
const key = String(row.mapKey ?? row.map ?? row.key ?? '').toLowerCase();
|
||||
if (!key) continue;
|
||||
|
||||
if (typeof row.winrate === 'number' && Number.isFinite(row.winrate)) {
|
||||
out[key] = row.winrate;
|
||||
continue;
|
||||
}
|
||||
|
||||
const wins = Number(row.wins ?? row.W ?? row.w ?? 0);
|
||||
const losses = Number(row.losses ?? row.L ?? row.l ?? 0);
|
||||
if (Number.isFinite(wins) && Number.isFinite(losses) && wins + losses > 0) {
|
||||
out[key] = (wins / (wins + losses)) * 100;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Winrates pro Map für einen Spieler.
|
||||
* Erwartetes Ergebnis (nach Normalisierung):
|
||||
* { "de_inferno": 53.2, "de_mirage": 47.8, ... } (Prozent 0..100)
|
||||
*
|
||||
* ➜ Passe die URL-Reihenfolge unten an deine API an.
|
||||
*/
|
||||
async function fetchWinrate(steamId: string): Promise<Record<string, number>> {
|
||||
// Cache-Hit?
|
||||
const cached = winrateCache.get(steamId);
|
||||
if (cached) return cached;
|
||||
|
||||
// Kandidaten-Endpoints (der erste, der 200 liefert, wird genommen)
|
||||
const candidates = [
|
||||
`/api/user/${steamId}/winrate`,
|
||||
`/api/user/${steamId}/map-stats`,
|
||||
];
|
||||
|
||||
let normalized: Record<string, number> = {};
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) continue;
|
||||
const json = await r.json().catch(() => null);
|
||||
normalized = normalizeWinrateResponse(json);
|
||||
// Wenn irgendwas Sinnvolles kam, abbrechen:
|
||||
if (Object.keys(normalized).length) break;
|
||||
} catch {
|
||||
// nächste URL probieren
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (nix gefunden) -> leeres Objekt
|
||||
if (!Object.keys(normalized).length) {
|
||||
normalized = {};
|
||||
}
|
||||
|
||||
winrateCache.set(steamId, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/* =================== Component =================== */
|
||||
|
||||
export default function MapVotePanel({ match }: Props) {
|
||||
@ -424,6 +529,13 @@ export default function MapVotePanel({ match }: Props) {
|
||||
teamLeftKey = 'teamB'
|
||||
teamRightKey = 'teamA'
|
||||
}
|
||||
|
||||
// Farben an Radar anlehnen
|
||||
const LEFT_RING = 'ring-2 ring-green-500/70 shadow-[0_10px_30px_rgba(34,197,94,0.25)]';
|
||||
const RIGHT_RING = 'ring-2 ring-red-500/70 shadow-[0_10px_30px_rgba(239,68,68,0.25)]';
|
||||
|
||||
const BASE_PANEL = 'relative rounded-lg p-2 transition-all duration-300 ease-out';
|
||||
const INACTIVE_FADE = 'opacity-75 grayscale-[5%]';
|
||||
|
||||
const teamLeft = (match as any)[teamLeftKey]
|
||||
const teamRight = (match as any)[teamRightKey]
|
||||
@ -716,13 +828,21 @@ export default function MapVotePanel({ match }: Props) {
|
||||
|
||||
{/* Hauptbereich */}
|
||||
{state && (
|
||||
<div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start">
|
||||
<div
|
||||
className="
|
||||
mt-0 grid
|
||||
grid-cols-1 md:[grid-template-columns:0.8fr_1.4fr_0.8fr]
|
||||
gap-6 md:gap-10 items-start
|
||||
"
|
||||
>
|
||||
{/* Linke Spalte (immer dein Team) */}
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col items-start max-w-[260px] md:max-w-[400px] gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
|
||||
// neu: komplette Spalte auf Mobile ausblenden
|
||||
'hidden md:flex',
|
||||
'flex-col items-start max-w-[260px] md:max-w-[400px] gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
|
||||
leftIsActiveTurn && !state?.locked
|
||||
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||
? 'bg-green-300 dark:bg-green-900 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||
: 'bg-transparent shadow-none',
|
||||
].join(' ')}
|
||||
>
|
||||
@ -758,7 +878,7 @@ export default function MapVotePanel({ match }: Props) {
|
||||
|
||||
{/* Mitte – Mappool / Winrate per Tabs */}
|
||||
{tab === 'pool' ? (
|
||||
<main className="w-full max-w-xl justify-self-center">
|
||||
<main className="w-full max-w-full md:max-w-xl justify-self-center">
|
||||
<ul className="flex flex-col gap-3">
|
||||
{sortedMapPool.map((map) => {
|
||||
const decision = decisionByMap.get(map)
|
||||
@ -891,29 +1011,36 @@ export default function MapVotePanel({ match }: Props) {
|
||||
) : (
|
||||
// Winrate-Tab
|
||||
<div className="flex-1 min-h-0 grid place-items-center">
|
||||
<div className="w-full max-w-xl h-full">
|
||||
<div className="relative w-full max-w-full md:max-w-xl xl:max-w-2xl aspect-[4/3] md:aspect-[16/10] rounded-xl border border-neutral-700 bg-neutral-900/40 p-4">
|
||||
<Chart
|
||||
type="radar"
|
||||
labels={activeMapLabels}
|
||||
height="auto"
|
||||
datasets={[
|
||||
{ label: teamLeft?.name ?? 'Team Links', data: teamRadarLeft,
|
||||
borderColor: 'rgba(54,162,235,0.9)', backgroundColor: 'rgba(54,162,235,0.20)', borderWidth: 2 },
|
||||
{ label: teamRight?.name ?? 'Team Rechts', data: teamRadarRight,
|
||||
borderColor: 'rgba(255,99,132,0.9)', backgroundColor: 'rgba(255,99,132,0.20)', borderWidth: 2 },
|
||||
{
|
||||
label: teamRight?.name ?? 'Team Rechts',
|
||||
data: teamRadarRight,
|
||||
borderColor: 'rgba(239,68,68,0.95)',
|
||||
backgroundColor: 'rgba(239,68,68,0.18)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
{
|
||||
label: teamLeft?.name ?? 'Team Links',
|
||||
data: teamRadarLeft,
|
||||
borderColor: 'rgba(34,197,94,0.95)',
|
||||
backgroundColor: 'rgba(34,197,94,0.18)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
radarIcons={activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`)}
|
||||
radarIconSize={32}
|
||||
radarIconOffset={20}
|
||||
radarIconSize={40}
|
||||
radarHideTicks={true}
|
||||
// ⬇️ Mapnamen unter den Icons zeigen
|
||||
radarIconLabels={true}
|
||||
radarIconLabelFont="12px Inter, system-ui, sans-serif"
|
||||
radarIconLabelColor="#ffffff"
|
||||
radarIconLabelMargin={4}
|
||||
radarMax={120}
|
||||
radarStepSize={20}
|
||||
radarAddRingOffset={false} // <- aus, wenn du echte % zeigst
|
||||
radarAddRingOffset={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -922,9 +1049,10 @@ export default function MapVotePanel({ match }: Props) {
|
||||
{/* Rechte Spalte (Gegner) */}
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col items-end max-w-[260px] md:max-w-[400px] gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
|
||||
'hidden md:flex',
|
||||
'flex-col items-end max-w-[260px] md:max-w-[400px] gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
|
||||
rightIsActiveTurn && !state?.locked
|
||||
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||
? 'bg-red-300 dark:bg-red-900 shadow-[0_0_2px_rgba(239,68,68,0.7)]'
|
||||
: 'bg-transparent shadow-none',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@ -130,7 +130,7 @@ export default function MiniCard({
|
||||
}`}>
|
||||
<span className="text-gray-800 dark:text-neutral-200 font-semibold text-sm mb-1 truncate px-2 max-w-[90%] text-center">{title}</span>
|
||||
<div className="pointer-events-auto" onPointerDown={stopDrag}>
|
||||
<Button className="max-w-[100px]" title={isInvite ? 'Zurückziehen' : 'Kicken'} color="red" variant="solid" size="sm" onClick={isInvite ? handleRevokeClick : handleKickClick} />
|
||||
<Button className="max-w-[120px]" title={isInvite ? 'Zurückziehen' : 'Kicken'} color="red" variant="solid" size="sm" onClick={isInvite ? handleRevokeClick : handleKickClick} />
|
||||
</div>
|
||||
{typeof onPromote === 'function' && (
|
||||
<div className="pointer-events-auto" onPointerDown={stopDrag}>
|
||||
@ -154,7 +154,7 @@ export default function MiniCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-gray-800 dark:text-neutral-200 text-center mt-1 truncate max-w-[100px] w-full block mb-1">
|
||||
<span className="text-sm text-gray-800 dark:text-neutral-200 text-center mt-1 truncate max-w-[120px] w-full block mb-1">
|
||||
{title}
|
||||
</span>
|
||||
|
||||
|
||||
@ -115,10 +115,7 @@ export default function Modal({
|
||||
border border-gray-200 dark:border-neutral-700
|
||||
shadow-2xs dark:shadow-neutral-700/70
|
||||
rounded-xl
|
||||
/* -> Höhe des Panels auf den Viewport begrenzen */
|
||||
max-h-[calc(100vh-56px)]
|
||||
/* -> Fix: Verhindert Höhen-Jumps bei Filterwechsel */
|
||||
min-h-[620px]
|
||||
"
|
||||
>
|
||||
{/* Header (fixe Höhe) */}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// TeamMemberView.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@ -83,6 +85,12 @@ export default function TeamMemberView({
|
||||
const [editedName, setEditedName] = useState(team.name || '')
|
||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||
|
||||
const [inviteKey, setInviteKey] = useState(0)
|
||||
const openInvite = () => {
|
||||
setInviteKey(k => k + 1) // erzwingt frischen Mount
|
||||
setShowInviteModal(true)
|
||||
}
|
||||
|
||||
// Cache-Busting fürs Logo
|
||||
const initialLogoVersion =
|
||||
(team as any).logoUpdatedAt
|
||||
@ -750,10 +758,7 @@ export default function TeamMemberView({
|
||||
<MiniCardDummy
|
||||
zoneId="inactive"
|
||||
title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
|
||||
onClick={() => {
|
||||
setShowInviteModal(false)
|
||||
setTimeout(() => setShowInviteModal(true), 0)
|
||||
}}
|
||||
onClick={openInvite}
|
||||
>
|
||||
<div className="flex items-center justify-center w-16 h-16 bg-white rounded-full text-black">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" viewBox="0 0 640 512" fill="currentColor">
|
||||
@ -836,6 +841,7 @@ export default function TeamMemberView({
|
||||
|
||||
{canInvite && (
|
||||
<InvitePlayersModal
|
||||
key={inviteKey}
|
||||
show={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
onSuccess={() => {}}
|
||||
@ -844,9 +850,10 @@ export default function TeamMemberView({
|
||||
)}
|
||||
{canAddDirect && (
|
||||
<InvitePlayersModal
|
||||
key={inviteKey}
|
||||
show={showInviteModal}
|
||||
onClose={() => setShowInviteModal(false)}
|
||||
onSuccess={() => {}}
|
||||
onSuccess={() => setShowInviteModal(false)}
|
||||
team={team}
|
||||
directAdd
|
||||
/>
|
||||
|
||||
@ -48,5 +48,8 @@ export async function POST(req: NextRequest) {
|
||||
teamId,
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
results: steamIds.map(sid => ({ steamId: sid, ok: true }))
|
||||
})
|
||||
}
|
||||
|
||||
146
src/app/api/user/[steamId]/map-stats/route.ts
Normal file
146
src/app/api/user/[steamId]/map-stats/route.ts
Normal file
@ -0,0 +1,146 @@
|
||||
// /src/app/api/user/[steamId]/map-stats/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||
|
||||
/** Map-Key normalisieren (z.B. "maps/de_inferno.bsp" -> "de_inferno") */
|
||||
function normMapKey(raw?: string | null) {
|
||||
return (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
||||
}
|
||||
|
||||
const MAP_ACTIVE_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.active] as const))
|
||||
const MAP_ORDER_BY_KEY = new Map(MAP_OPTIONS.map((o, idx) => [o.key, idx] as const))
|
||||
const IGNORED_KEYS = new Set(['lobby_mapvote'])
|
||||
|
||||
/** Gewinner-Seite ermitteln; wenn scoreA/scoreB gleich => Tie */
|
||||
function computeOutcome(m: {
|
||||
winnerTeam: string | null
|
||||
teamAId: string | null
|
||||
teamBId: string | null
|
||||
scoreA: number | null
|
||||
scoreB: number | null
|
||||
}): 'A' | 'B' | 'TIE' | null {
|
||||
if (typeof m.scoreA === 'number' && typeof m.scoreB === 'number') {
|
||||
if (m.scoreA > m.scoreB) return 'A'
|
||||
if (m.scoreB > m.scoreA) return 'B'
|
||||
return 'TIE'
|
||||
}
|
||||
const w = (m.winnerTeam ?? '').trim().toLowerCase()
|
||||
if (w) {
|
||||
if (w === 'a' || w === (m.teamAId ?? '').toLowerCase()) return 'A'
|
||||
if (w === 'b' || w === (m.teamBId ?? '').toLowerCase()) return 'B'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/user/:steamId/map-stats?types=premier,competitive&onlyActive=true&limit=1000
|
||||
*
|
||||
* Antwort (Array):
|
||||
* [
|
||||
* { mapKey: "de_inferno", wins: 10, losses: 8, ties: 1, total: 19, winrate: 55.3 },
|
||||
* ...
|
||||
* ]
|
||||
* winrate = (W + 0.5*T) / (W+L+T) * 100, auf 1 Nachkommastelle gerundet
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: { steamId: string } }) {
|
||||
const steamId = params.steamId
|
||||
if (!steamId) return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const typesParam = searchParams.get('types')
|
||||
const types = typesParam ? typesParam.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
const onlyActive = (searchParams.get('onlyActive') ?? 'true').toLowerCase() !== 'false'
|
||||
const limitParam = Number(searchParams.get('limit') ?? '1000')
|
||||
const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(limitParam, 5000) : 1000
|
||||
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
players: { some: { steamId } },
|
||||
...(types.length ? { matchType: { in: types } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
map: true,
|
||||
scoreA: true, scoreB: true,
|
||||
teamAId: true, teamBId: true,
|
||||
winnerTeam: true,
|
||||
teamAUsers: { select: { steamId: true } },
|
||||
teamBUsers: { select: { steamId: true } },
|
||||
players: {
|
||||
where: { steamId },
|
||||
select: { teamId: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
||||
take: limit,
|
||||
})
|
||||
|
||||
type Agg = { wins: number; losses: number; ties: number; total: number }
|
||||
const byMap: Record<string, Agg> = {}
|
||||
|
||||
for (const m of matches) {
|
||||
const keyRaw = normMapKey(m.map) || 'unknown'
|
||||
if (IGNORED_KEYS.has(keyRaw)) continue
|
||||
if (onlyActive && MAP_ACTIVE_BY_KEY.has(keyRaw) && !MAP_ACTIVE_BY_KEY.get(keyRaw)) continue
|
||||
|
||||
// Team-Zuordnung robust bestimmen
|
||||
const inA = m.teamAUsers.some(u => u.steamId === steamId)
|
||||
const inB = m.teamBUsers.some(u => u.steamId === steamId)
|
||||
|
||||
let side: 'A' | 'B' | null = null
|
||||
if (inA) side = 'A'
|
||||
else if (inB) side = 'B'
|
||||
else {
|
||||
const teamId = m.players[0]?.teamId ?? null
|
||||
if (teamId && m.teamAId && teamId === m.teamAId) side = 'A'
|
||||
else if (teamId && m.teamBId && teamId === m.teamBId) side = 'B'
|
||||
}
|
||||
if (!side) continue
|
||||
|
||||
const outcome = computeOutcome({
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
teamAId: m.teamAId ?? null,
|
||||
teamBId: m.teamBId ?? null,
|
||||
scoreA: m.scoreA ?? null,
|
||||
scoreB: m.scoreB ?? null,
|
||||
})
|
||||
if (!outcome) continue
|
||||
|
||||
const key = keyRaw
|
||||
if (!byMap[key]) byMap[key] = { wins: 0, losses: 0, ties: 0, total: 0 }
|
||||
|
||||
if (outcome === 'TIE') {
|
||||
byMap[key].ties += 1
|
||||
byMap[key].total += 1
|
||||
} else if (outcome === side) {
|
||||
byMap[key].wins += 1
|
||||
byMap[key].total += 1
|
||||
} else {
|
||||
byMap[key].losses += 1
|
||||
byMap[key].total += 1
|
||||
}
|
||||
}
|
||||
|
||||
const keys = Object.keys(byMap).sort((a, b) => {
|
||||
const ia = MAP_ORDER_BY_KEY.has(a) ? (MAP_ORDER_BY_KEY.get(a) as number) : Number.POSITIVE_INFINITY
|
||||
const ib = MAP_ORDER_BY_KEY.has(b) ? (MAP_ORDER_BY_KEY.get(b) as number) : Number.POSITIVE_INFINITY
|
||||
if (ia !== ib) return ia - ib
|
||||
return a.localeCompare(b, 'de', { sensitivity: 'base' })
|
||||
})
|
||||
|
||||
const rows = keys.map(k => {
|
||||
const it = byMap[k]
|
||||
const denom = it.total
|
||||
const ratio = denom > 0 ? (it.wins + 0.5 * it.ties) / denom : 0
|
||||
const winrate = Math.round(ratio * 1000) / 10 // 1 Nachkommastelle
|
||||
return { mapKey: k, wins: it.wins, losses: it.losses, ties: it.ties, total: it.total, winrate }
|
||||
})
|
||||
|
||||
return NextResponse.json(rows, { headers: { 'Cache-Control': 'no-store' } })
|
||||
} catch (err) {
|
||||
console.error('[map-stats] Fehler:', err)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/api/user/[steamId]/winrate/route.ts
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||
|
||||
@ -377,7 +377,7 @@ const config = {
|
||||
"value": "prisma-client-js"
|
||||
},
|
||||
"output": {
|
||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||
"fromEnvVar": null
|
||||
},
|
||||
"config": {
|
||||
@ -391,7 +391,7 @@ const config = {
|
||||
}
|
||||
],
|
||||
"previewFeatures": [],
|
||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||
"isCustomOutput": true
|
||||
},
|
||||
"relativeEnvPaths": {
|
||||
|
||||
@ -378,7 +378,7 @@ const config = {
|
||||
"value": "prisma-client-js"
|
||||
},
|
||||
"output": {
|
||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||
"fromEnvVar": null
|
||||
},
|
||||
"config": {
|
||||
@ -392,7 +392,7 @@ const config = {
|
||||
}
|
||||
],
|
||||
"previewFeatures": [],
|
||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||
"isCustomOutput": true
|
||||
},
|
||||
"relativeEnvPaths": {
|
||||
|
||||
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp18116
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp18116
Normal file
Binary file not shown.
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp22732
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp22732
Normal file
Binary file not shown.
@ -377,7 +377,7 @@ const config = {
|
||||
"value": "prisma-client-js"
|
||||
},
|
||||
"output": {
|
||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||
"fromEnvVar": null
|
||||
},
|
||||
"config": {
|
||||
@ -391,7 +391,7 @@ const config = {
|
||||
}
|
||||
],
|
||||
"previewFeatures": [],
|
||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||
"isCustomOutput": true
|
||||
},
|
||||
"relativeEnvPaths": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user