added chart.js

This commit is contained in:
Linrador 2025-10-01 15:01:44 +02:00
parent 6d187fc885
commit bd365390d5
20 changed files with 1062 additions and 492 deletions

121
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -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 &amp; 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">

View File

@ -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">

View File

@ -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)

View File

@ -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

View File

@ -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 && (

View File

@ -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(' ')}
>

View File

@ -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>

View File

@ -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) */}

View File

@ -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
/>

View File

@ -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 }))
})
}

View 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 })
}
}

View File

@ -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'

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {