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",
|
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||||
"@preline/dropdown": "^3.0.1",
|
"@preline/dropdown": "^3.0.1",
|
||||||
"@preline/tooltip": "^3.0.0",
|
"@preline/tooltip": "^3.0.0",
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.3",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csgo-sharecode": "^3.1.2",
|
"csgo-sharecode": "^3.1.2",
|
||||||
@ -42,7 +42,6 @@
|
|||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"preline": "^3.0.1",
|
"preline": "^3.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-chartjs-2": "^5.3.0",
|
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
"undici": "^7.15.0",
|
"undici": "^7.15.0",
|
||||||
@ -61,7 +60,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.0",
|
"eslint-config-next": "15.3.0",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.3",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
@ -86,6 +85,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"regenerator-runtime": "^0.14.0"
|
"regenerator-runtime": "^0.14.0"
|
||||||
},
|
},
|
||||||
@ -123,7 +123,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@ -1578,6 +1577,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
@ -1598,9 +1598,9 @@
|
|||||||
"license": "Licensed under MIT and Preline UI Fair Use License"
|
"license": "Licensed under MIT and Preline UI Fair Use License"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.16.2",
|
"version": "6.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.3.tgz",
|
||||||
"integrity": "sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==",
|
"integrity": "sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1620,9 +1620,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/config": {
|
"node_modules/@prisma/config": {
|
||||||
"version": "6.16.2",
|
"version": "6.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz",
|
||||||
"integrity": "sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==",
|
"integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1633,53 +1633,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/debug": {
|
"node_modules/@prisma/debug": {
|
||||||
"version": "6.16.2",
|
"version": "6.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz",
|
||||||
"integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==",
|
"integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "6.16.2",
|
"version": "6.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz",
|
||||||
"integrity": "sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==",
|
"integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.16.2",
|
"@prisma/debug": "6.16.3",
|
||||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
|
||||||
"@prisma/fetch-engine": "6.16.2",
|
"@prisma/fetch-engine": "6.16.3",
|
||||||
"@prisma/get-platform": "6.16.2"
|
"@prisma/get-platform": "6.16.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines-version": {
|
"node_modules/@prisma/engines-version": {
|
||||||
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
"version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz",
|
||||||
"integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
|
"integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "6.16.2",
|
"version": "6.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz",
|
||||||
"integrity": "sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==",
|
"integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.16.2",
|
"@prisma/debug": "6.16.3",
|
||||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
|
||||||
"@prisma/get-platform": "6.16.2"
|
"@prisma/get-platform": "6.16.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/get-platform": {
|
"node_modules/@prisma/get-platform": {
|
||||||
"version": "6.16.2",
|
"version": "6.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz",
|
||||||
"integrity": "sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==",
|
"integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.16.2"
|
"@prisma/debug": "6.16.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
@ -2068,7 +2068,6 @@
|
|||||||
"integrity": "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw==",
|
"integrity": "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.19.2"
|
"undici-types": "~6.19.2"
|
||||||
}
|
}
|
||||||
@ -2086,7 +2085,6 @@
|
|||||||
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
|
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@ -2184,7 +2182,6 @@
|
|||||||
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
|
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.30.1",
|
"@typescript-eslint/scope-manager": "8.30.1",
|
||||||
"@typescript-eslint/types": "8.30.1",
|
"@typescript-eslint/types": "8.30.1",
|
||||||
@ -2618,7 +2615,6 @@
|
|||||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -3138,7 +3134,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@ -3288,6 +3283,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@ -3428,7 +3424,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
@ -3885,7 +3880,6 @@
|
|||||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@ -4060,7 +4054,6 @@
|
|||||||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.8",
|
"array-includes": "^3.1.8",
|
||||||
@ -5409,6 +5402,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
@ -5837,6 +5831,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yallist": "^4.0.0"
|
"yallist": "^4.0.0"
|
||||||
},
|
},
|
||||||
@ -6024,7 +6019,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.3.0.tgz",
|
||||||
"integrity": "sha512-k0MgP6BsK8cZ73wRjMazl2y2UcXj49ZXLDEgx6BikWuby/CN+nh81qFFI16edgd7xYpe/jj2OZEIwCoqnzz0bQ==",
|
"integrity": "sha512-k0MgP6BsK8cZ73wRjMazl2y2UcXj49ZXLDEgx6BikWuby/CN+nh81qFFI16edgd7xYpe/jj2OZEIwCoqnzz0bQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.3.0",
|
"@next/env": "15.3.0",
|
||||||
"@swc/counter": "0.1.3",
|
"@swc/counter": "0.1.3",
|
||||||
@ -6317,7 +6311,8 @@
|
|||||||
"version": "0.9.15",
|
"version": "0.9.15",
|
||||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
@ -6334,6 +6329,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
@ -6462,6 +6458,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
||||||
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10.13.0 || >=12.0.0"
|
"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",
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||||
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pretty-format": "^3.8.0"
|
"pretty-format": "^3.8.0"
|
||||||
},
|
},
|
||||||
@ -6778,19 +6776,19 @@
|
|||||||
"version": "3.8.0",
|
"version": "3.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.16.2",
|
"version": "6.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz",
|
||||||
"integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==",
|
"integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/config": "6.16.2",
|
"@prisma/config": "6.16.3",
|
||||||
"@prisma/engines": "6.16.2"
|
"@prisma/engines": "6.16.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"prisma": "build/index.js"
|
"prisma": "build/index.js"
|
||||||
@ -6904,27 +6902,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
@ -6994,7 +6980,8 @@
|
|||||||
"version": "0.14.1",
|
"version": "0.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
@ -7651,8 +7638,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
||||||
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
@ -7709,7 +7695,6 @@
|
|||||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -7935,7 +7920,6 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -8191,7 +8175,8 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/yn": {
|
"node_modules/yn": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||||
"@preline/dropdown": "^3.0.1",
|
"@preline/dropdown": "^3.0.1",
|
||||||
"@preline/tooltip": "^3.0.0",
|
"@preline/tooltip": "^3.0.0",
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.3",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csgo-sharecode": "^3.1.2",
|
"csgo-sharecode": "^3.1.2",
|
||||||
@ -48,7 +48,6 @@
|
|||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"preline": "^3.0.1",
|
"preline": "^3.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-chartjs-2": "^5.3.0",
|
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
"undici": "^7.15.0",
|
"undici": "^7.15.0",
|
||||||
@ -67,7 +66,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.0",
|
"eslint-config-next": "15.3.0",
|
||||||
"prisma": "^6.16.2",
|
"prisma": "^6.16.3",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.19.4",
|
"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 {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
|
type ChartType as ChartJSType,
|
||||||
|
type ChartData,
|
||||||
|
type ChartOptions,
|
||||||
|
type Plugin,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
RadialLinearScale,
|
RadialLinearScale,
|
||||||
@ -13,10 +27,8 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
Title,
|
Title,
|
||||||
Filler,
|
Filler,
|
||||||
type Plugin,
|
RadarController,
|
||||||
} from 'chart.js'
|
} from 'chart.js';
|
||||||
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@ -26,307 +38,373 @@ ChartJS.register(
|
|||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
ArcElement,
|
ArcElement,
|
||||||
|
RadarController,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
Title,
|
Title,
|
||||||
Filler
|
Filler
|
||||||
)
|
);
|
||||||
|
|
||||||
type ChartType =
|
export type ChartType =
|
||||||
| 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'polararea' | 'bubble' | 'scatter'
|
| 'bar'
|
||||||
|
| 'line'
|
||||||
|
| 'pie'
|
||||||
|
| 'doughnut'
|
||||||
|
| 'radar'
|
||||||
|
| 'polarArea'
|
||||||
|
| 'bubble'
|
||||||
|
| 'scatter';
|
||||||
|
|
||||||
type BaseDataset = {
|
export type ChartHandle = {
|
||||||
label: string
|
chart: ChartJS | null; // <-- ungenauer, aber kompatibel
|
||||||
data: number[]
|
update: (mutator?: (c: ChartJS) => void) => void;
|
||||||
backgroundColor?: string | string[]
|
};
|
||||||
borderColor?: string
|
|
||||||
borderWidth?: number
|
type SimpleDataset = {
|
||||||
fill?: boolean | number | 'origin' | 'start' | 'end'
|
label: string;
|
||||||
tension?: number
|
data: number[];
|
||||||
spanGaps?: boolean
|
backgroundColor?: string | string[];
|
||||||
|
borderColor?: string | string[];
|
||||||
|
borderWidth?: number;
|
||||||
|
pointRadius?: number | number[];
|
||||||
|
pointHoverRadius?: number | number[];
|
||||||
|
fill?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseProps<TType extends ChartJSType = ChartJSType> = {
|
||||||
|
type: TType;
|
||||||
|
|
||||||
|
data?: ChartData<TType>;
|
||||||
|
labels?: string[];
|
||||||
|
datasets?: SimpleDataset[];
|
||||||
|
|
||||||
|
options?: ChartOptions<TType>;
|
||||||
|
plugins?: Plugin<TType>[];
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
height?: number | string;
|
||||||
|
redraw?: boolean;
|
||||||
|
onReady?: (chart: ChartJS) => void;
|
||||||
|
ariaLabel?: string;
|
||||||
|
|
||||||
|
// Radar-Extras
|
||||||
|
radarIcons?: string[];
|
||||||
|
radarIconSize?: number;
|
||||||
|
radarIconLabels?: boolean;
|
||||||
|
radarIconLabelFont?: string;
|
||||||
|
radarIconLabelColor?: string;
|
||||||
|
radarIconLabelMargin?: number;
|
||||||
|
radarHideTicks?: boolean;
|
||||||
|
radarMax?: number;
|
||||||
|
radarStepSize?: number;
|
||||||
|
radarAddRingOffset?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChartProps = {
|
function _Chart<TType extends ChartJSType = ChartJSType>(
|
||||||
type: ChartType
|
props: BaseProps<TType>,
|
||||||
labels: string[]
|
ref: React.Ref<ChartHandle>
|
||||||
datasets: BaseDataset[]
|
) {
|
||||||
title?: string
|
const {
|
||||||
|
|
||||||
/** 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
|
|
||||||
|
|
||||||
className?: string
|
|
||||||
style?: React.CSSProperties
|
|
||||||
|
|
||||||
/** Radar-Achse */
|
|
||||||
radarMin?: number // default 0
|
|
||||||
radarMax?: number // default 120 (wegen +20 Offset)
|
|
||||||
radarStepSize?: number // default 20
|
|
||||||
radarHideTicks?: boolean // default true
|
|
||||||
|
|
||||||
/** Radar-Darstellung */
|
|
||||||
radarFillMode?: boolean | number | 'origin' | 'start' | 'end' // default: true
|
|
||||||
radarTension?: number // default: 0
|
|
||||||
radarSpanGaps?: boolean // default: false
|
|
||||||
|
|
||||||
/** Werte auf den Ringen +stepSize nach außen schieben (Tooltips bleiben original) */
|
|
||||||
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,
|
type,
|
||||||
|
data,
|
||||||
labels,
|
labels,
|
||||||
datasets,
|
datasets,
|
||||||
title,
|
options,
|
||||||
height = 300,
|
plugins,
|
||||||
aspectRatio = 2,
|
className,
|
||||||
hideLabels,
|
|
||||||
className = '',
|
|
||||||
style,
|
style,
|
||||||
|
height,
|
||||||
// Radar defaults: 0..120 mit 20er Ringen (0/20/40/60/80/100/120)
|
redraw = false,
|
||||||
radarMin = 0,
|
onReady,
|
||||||
radarMax = 120,
|
ariaLabel,
|
||||||
radarStepSize = 20,
|
|
||||||
radarHideTicks = true,
|
|
||||||
|
|
||||||
radarFillMode = true,
|
|
||||||
radarTension = 0,
|
|
||||||
radarSpanGaps = false,
|
|
||||||
|
|
||||||
radarAddRingOffset = true,
|
|
||||||
|
|
||||||
radarIcons,
|
radarIcons,
|
||||||
radarIconSize = 28,
|
radarIconSize = 40,
|
||||||
radarIconOffset = 6,
|
|
||||||
|
|
||||||
// ⬇️ neu
|
|
||||||
radarIconLabels = false,
|
radarIconLabels = false,
|
||||||
radarIconLabelFont = '12px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, sans-serif',
|
radarIconLabelFont = '12px Inter, system-ui, sans-serif',
|
||||||
radarIconLabelColor = '#fff',
|
radarIconLabelColor = '#ffffff',
|
||||||
radarIconLabelMargin = 4,
|
radarIconLabelMargin = 6,
|
||||||
}: ChartProps) {
|
radarHideTicks = false,
|
||||||
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,
|
radarMax,
|
||||||
])
|
radarStepSize,
|
||||||
|
radarAddRingOffset = false,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const data = useMemo(
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
() => ({ labels, datasets: preparedDatasets }),
|
const chartRef = useRef<ChartJS | null>(null); // <-- hier
|
||||||
[labels, preparedDatasets]
|
|
||||||
)
|
|
||||||
|
|
||||||
// -------- Icon-Plugin (nur für Radar) --------
|
const prevTypeRef = useRef<ChartJSType | null>(null);
|
||||||
const imageCacheRef = useRef<Map<string, HTMLImageElement>>(new Map())
|
|
||||||
|
|
||||||
const resolveIconUrl = (i: number, label: string): string | null => {
|
const autoData = useMemo<ChartData<TType> | undefined>(() => {
|
||||||
if (!radarIcons) return null
|
if (data) return data;
|
||||||
if (Array.isArray(radarIcons)) return radarIcons[i] ?? null
|
if (!labels || !datasets) return undefined;
|
||||||
return radarIcons[label] ?? null
|
return { labels, datasets: datasets as any } as ChartData<TType>;
|
||||||
|
}, [data, labels, datasets]);
|
||||||
|
|
||||||
|
const radarScaleOpts = useMemo(() => {
|
||||||
|
if (type !== 'radar') return undefined;
|
||||||
|
|
||||||
|
const gridColor = 'rgba(255,255,255,0.10)'; // sichtbarer
|
||||||
|
const angleColor = 'rgba(255,255,255,0.12)';
|
||||||
|
|
||||||
|
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 } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// exakt auf Radar typisieren
|
return { r };
|
||||||
const radarIconsPlugin: Plugin<'radar'> | undefined = useMemo(() => {
|
}, [type, radarHideTicks, radarStepSize, radarMax]);
|
||||||
if (!isRadar || !radarIcons) return undefined
|
|
||||||
|
|
||||||
const plugin: Plugin<'radar'> = {
|
const [radarPlugin] = useState<Plugin<'radar'>>(() => ({
|
||||||
id: 'radar-icons',
|
id: 'radarIconsPlugin',
|
||||||
afterDraw: (chart) => {
|
afterDatasetsDraw(chart) {
|
||||||
const scale: any = chart.scales?.r
|
const ctx = chart.ctx as CanvasRenderingContext2D;
|
||||||
if (!scale) return
|
const scale: any = (chart as any).scales?.r;
|
||||||
const ctx = chart.ctx
|
if (!scale) return;
|
||||||
const n = (chart.data.labels ?? []).length
|
|
||||||
if (!n) return
|
|
||||||
|
|
||||||
// Zeichnen knapp außerhalb des äußersten Rings
|
const lbls = chart.data.labels as string[] | undefined;
|
||||||
const radiusValue = (scale.max ?? radarMax) + radarIconOffset
|
if (!lbls?.length) return;
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
const icons = radarIcons ?? [];
|
||||||
const label = String(chart.data.labels?.[i] ?? '')
|
|
||||||
const url = resolveIconUrl(i, label)
|
|
||||||
if (!url) continue
|
|
||||||
|
|
||||||
let img = imageCacheRef.current.get(url)
|
// --- Clip auf gesamte Canvas setzen, damit außerhalb der chartArea gemalt wird
|
||||||
if (!img) {
|
ctx.save();
|
||||||
img = new Image()
|
// einige Browser unterstützen resetTransform nicht, daher optional
|
||||||
img.crossOrigin = 'anonymous'
|
(ctx as any).resetTransform?.();
|
||||||
img.src = url
|
ctx.beginPath();
|
||||||
imageCacheRef.current.set(url, img)
|
ctx.rect(0, 0, chart.width, chart.height);
|
||||||
img.onload = () => { try { chart.draw() } catch {} }
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!img.complete) continue
|
|
||||||
|
|
||||||
const pos = scale.getPointPositionForValue(i, radiusValue) // (index, value)
|
// Label unter dem Icon (screen-vertikal)
|
||||||
const size = radarIconSize
|
|
||||||
const x = pos.x - size / 2
|
|
||||||
const y = pos.y - size / 2
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
// ⬇️ Label unter dem Icon (optional)
|
|
||||||
if (radarIconLabels) {
|
if (radarIconLabels) {
|
||||||
const text = label
|
ctx.fillText(String(lbls[i] ?? ''), cx, cy + half + radarIconLabelMargin);
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.restore(); // Clip/State wiederherstellen
|
||||||
},
|
},
|
||||||
}
|
}));
|
||||||
return plugin
|
|
||||||
}, [isRadar, radarIcons, radarIconOffset, radarIconSize, radarMax])
|
|
||||||
|
|
||||||
// -------- Optionen --------
|
const mergedOptions = useMemo<ChartOptions<TType>>(() => {
|
||||||
const options = useMemo(() => {
|
const base = {
|
||||||
const base: any = {
|
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: !isAutoHeight,
|
maintainAspectRatio: true,
|
||||||
aspectRatio: !isAutoHeight ? aspectRatio : undefined,
|
animation: { duration: 250 },
|
||||||
layout: {
|
} as const;
|
||||||
padding: { top: 50, right: 0, bottom: 50, left: 0 },
|
|
||||||
},
|
// Start: base -> user options
|
||||||
plugins: {
|
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 },
|
legend: { display: false },
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
tooltip: {
|
...(options?.plugins ?? {}),
|
||||||
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}%`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isRadar) {
|
// --- Layout-Padding für Icons/Labels am äußeren Ring --------------------
|
||||||
base.scales = {
|
// Font-Px aus z.B. "12px Inter, system-ui, sans-serif" extrahieren
|
||||||
r: {
|
const fontPx = (() => {
|
||||||
min: radarMin,
|
const m = /(\d+(?:\.\d+)?)px/i.exec(radarIconLabelFont ?? '');
|
||||||
max: radarMax,
|
return m ? parseFloat(m[1]) : 12;
|
||||||
ticks: {
|
})();
|
||||||
display: !radarHideTicks,
|
|
||||||
stepSize: radarStepSize,
|
// Platzbedarf: Icon + (optional) Labelhöhe + kleiner Sicherheitsrand
|
||||||
showLabelBackdrop: false,
|
const pad = Math.round(
|
||||||
backdropColor: 'transparent',
|
(radarIconSize ?? 40) +
|
||||||
callback: (v: number) => `${v}%`,
|
(radarIconLabels ? (fontPx + (radarIconLabelMargin ?? 6)) : 0) +
|
||||||
},
|
6
|
||||||
angleLines: { color: 'rgba(255,255,255,0.08)' },
|
);
|
||||||
grid: { color: 'rgba(255,255,255,0.08)' },
|
|
||||||
pointLabels:{ display: false },
|
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 (hideLabels) {
|
// ------------------------------------------------------------------------
|
||||||
base.scales = { x: { display: false }, y: { display: false } };
|
} else if (options?.scales) {
|
||||||
|
(o as any).scales = {
|
||||||
|
...(options.scales as any),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return o;
|
||||||
}, [
|
}, [
|
||||||
title, hideLabels, isAutoHeight, aspectRatio, isRadar,
|
type,
|
||||||
radarMin, radarMax, radarStepSize, radarHideTicks,
|
options,
|
||||||
originalDatasets, radarIconSize
|
radarScaleOpts,
|
||||||
|
radarIconSize,
|
||||||
|
radarIconLabels,
|
||||||
|
radarIconLabelFont,
|
||||||
|
radarIconLabelMargin,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// -------- Render (typsicher) --------
|
const mergedPlugins = useMemo<Plugin<TType>[]>(() => {
|
||||||
const wrapperStyle: React.CSSProperties = isAutoHeight
|
const list: Plugin<TType>[] = [];
|
||||||
? { width: '100%', height: '100%', position: 'relative', ...style }
|
if (plugins?.length) list.push(...plugins);
|
||||||
: { width: '100%', height: typeof height === 'number' ? height : undefined, ...style }
|
if (type === 'radar') list.push(radarPlugin as any);
|
||||||
if (isRadar) {
|
return list;
|
||||||
// Nur hier das streng typisierte Radar-Plugin übergeben
|
}, [plugins, type, radarPlugin]);
|
||||||
const radarPlugins = radarIconsPlugin ? [radarIconsPlugin] as Plugin<'radar'>[] : undefined
|
|
||||||
return (
|
const config = useMemo(
|
||||||
<div className={className} style={wrapperStyle}>
|
() => ({
|
||||||
<Radar
|
type,
|
||||||
data={data}
|
data: (autoData ?? { labels: [], datasets: [] }) as ChartData<TType>,
|
||||||
options={options}
|
options: mergedOptions,
|
||||||
plugins={radarPlugins}
|
plugins: mergedPlugins,
|
||||||
style={{ height: '100%', width: '100%' }} // <— Canvas füllt Parent
|
}),
|
||||||
/>
|
[type, autoData, mergedOptions, mergedPlugins]
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
|
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]);
|
||||||
|
|
||||||
// andere Chart-Typen ohne Radar-Plugin
|
useEffect(() => {
|
||||||
const chartMap = {
|
const c = chartRef.current;
|
||||||
line: Line,
|
if (!c || redraw || prevTypeRef.current !== type) return;
|
||||||
bar: Bar,
|
(c as any).data = (autoData ?? c.data);
|
||||||
doughnut: Doughnut,
|
(c as any).options = mergedOptions;
|
||||||
polararea: PolarArea,
|
c.update();
|
||||||
bubble: Bubble,
|
}, [autoData, mergedOptions, type, redraw]);
|
||||||
pie: Pie,
|
|
||||||
scatter: Scatter,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
const NonRadar = chartMap[type as Exclude<ChartType, 'radar'>]
|
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 (
|
return (
|
||||||
<div className={className} style={wrapperStyle}>
|
<div className={className} style={wrapperStyle}>
|
||||||
<NonRadar
|
<canvas ref={canvasRef} role="img" aria-label={ariaLabel ?? 'Chart'} />
|
||||||
data={data}
|
|
||||||
options={options}
|
|
||||||
style={{ height: '100%', width: '100%' }} // <— auch für andere Typen
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Chart = forwardRef(_Chart) as <TType extends ChartJSType = ChartJSType>(
|
||||||
|
p: BaseProps<TType> & { ref?: React.Ref<ChartHandle> }
|
||||||
|
) => ReturnType<typeof _Chart>;
|
||||||
|
|
||||||
|
export default Chart;
|
||||||
|
|||||||
@ -198,6 +198,10 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
const [matchDateStr, setMatchDateStr] = useState<string>(defaults.dateStr) // YYYY-MM-DD
|
const [matchDateStr, setMatchDateStr] = useState<string>(defaults.dateStr) // YYYY-MM-DD
|
||||||
const [matchTimeStr, setMatchTimeStr] = useState<string>(defaults.timeStr) // HH:MM
|
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(
|
const teamById = useCallback(
|
||||||
(id?: string) => teams.find(t => t.id === id),
|
(id?: string) => teams.find(t => t.id === id),
|
||||||
[teams]
|
[teams]
|
||||||
@ -562,7 +566,6 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
onSave={handleCreate}
|
onSave={handleCreate}
|
||||||
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
|
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
|
||||||
closeButtonColor="blue"
|
closeButtonColor="blue"
|
||||||
hideCloseButton={!canSave}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Team A */}
|
{/* Team A */}
|
||||||
@ -618,7 +621,9 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Datum & Uhrzeit */}
|
{/* Datum & Uhrzeit */}
|
||||||
<div className="grid grid-cols-2 gap-3 mt-3">
|
<div className="mt-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
|
||||||
|
{/* Links: Datum */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Datum</label>
|
<label className="block text-sm font-medium mb-1">Datum</label>
|
||||||
<input
|
<input
|
||||||
@ -629,20 +634,42 @@ export default function CommunityMatchList({ matchType }: Props) {
|
|||||||
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"
|
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>
|
||||||
|
|
||||||
|
{/* Rechts: Uhrzeit (HH + MM in Vierteln) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Uhrzeit</label>
|
<label className="block text-sm font-medium mb-1">Uhrzeit</label>
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="time"
|
{/* Stunde */}
|
||||||
value={matchTimeStr}
|
<select
|
||||||
onChange={(e) => setMatchTimeStr(e.target.value)}
|
value={matchTimeStr.split(':')[0]}
|
||||||
step={300} // 5-Minuten-Schritte
|
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"
|
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>
|
</div>
|
||||||
<p className="text-[11px] text-gray-500 dark:text-neutral-400">
|
</div>
|
||||||
Die Uhrzeit wird als lokale Zeit gespeichert.
|
|
||||||
|
<p className="text-[11px] text-gray-500 dark:text-neutral-400 mt-1">
|
||||||
|
Datum & Uhrzeit werden als lokale Zeit gespeichert.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Best-of */}
|
{/* Best-of */}
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
|
|||||||
@ -9,6 +9,10 @@ import LoadingSpinner from '../components/LoadingSpinner' // ⬅️ NEU
|
|||||||
|
|
||||||
type TeamOption = { id: string; name: string; logo?: string | null }
|
type TeamOption = { id: string; name: string; logo?: string | null }
|
||||||
|
|
||||||
|
type ZonedParts = {
|
||||||
|
year: number; month: number; day: number; hour: number; minute: number;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
show: boolean
|
show: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@ -25,6 +29,45 @@ type Props = {
|
|||||||
defaultBestOf?: 3 | 5
|
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({
|
export default function EditMatchMetaModal({
|
||||||
show,
|
show,
|
||||||
onClose,
|
onClose,
|
||||||
@ -56,7 +99,19 @@ export default function EditMatchMetaModal({
|
|||||||
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
||||||
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
||||||
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
|
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 [bestOf, setBestOf] = useState<3 | 5>(normalizeBestOf(defaultBestOf))
|
||||||
|
|
||||||
const [teams, setTeams] = useState<TeamOption[]>([])
|
const [teams, setTeams] = useState<TeamOption[]>([])
|
||||||
@ -139,10 +194,20 @@ export default function EditMatchMetaModal({
|
|||||||
setTitle(j?.title ?? '')
|
setTitle(j?.title ?? '')
|
||||||
setTeamAId(j?.teamAId ?? '')
|
setTeamAId(j?.teamAId ?? '')
|
||||||
setTeamBId(j?.teamBId ?? '')
|
setTeamBId(j?.teamBId ?? '')
|
||||||
setDate(toDatetimeLocal(j?.matchDate ?? j?.demoDate ?? null))
|
const dt = isoToLocalDateTimeStrings(j?.matchDate ?? j?.demoDate ?? null, userTZ);
|
||||||
setVoteLead(
|
setMatchDateStr(dt.dateStr);
|
||||||
Number.isFinite(Number(j?.mapVote?.leadMinutes)) ? Number(j.mapVote.leadMinutes) : 60
|
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)
|
const boFromMeta = normalizeBestOf(j?.bestOf)
|
||||||
setBestOf(boFromMeta)
|
setBestOf(boFromMeta)
|
||||||
@ -182,22 +247,41 @@ export default function EditMatchMetaModal({
|
|||||||
/* ───────── Validation ───────── */
|
/* ───────── Validation ───────── */
|
||||||
const canSave = useMemo(() => {
|
const canSave = useMemo(() => {
|
||||||
if (saving || loadingMeta) return false
|
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
|
if (teamAId && teamBId && teamAId === teamBId) return false
|
||||||
return true
|
return true
|
||||||
}, [saving, loadingMeta, date, teamAId, teamBId])
|
}, [saving, loadingMeta, teamAId, teamBId])
|
||||||
|
|
||||||
/* ───────── Save ───────── */
|
/* ───────── Save ───────── */
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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 = {
|
const body = {
|
||||||
title: title || null,
|
title: title || null,
|
||||||
teamAId: teamAId || null,
|
teamAId: teamAId || null,
|
||||||
teamBId: teamBId || null,
|
teamBId: teamBId || null,
|
||||||
matchDate: date ? new Date(date).toISOString() : null,
|
matchDate: (matchDateStr && matchTimeStr) ? combineLocalDateTime(matchDateStr, matchTimeStr) : null,
|
||||||
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
|
voteLeadMinutes: leadMinutes,
|
||||||
bestOf,
|
bestOf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,36 +376,116 @@ export default function EditMatchMetaModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Datum/Uhrzeit */}
|
{/* Datum & Uhrzeit (wie in CommunityMatchList) */}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1">Datum & Uhrzeit</label>
|
<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
|
<input
|
||||||
type="datetime-local"
|
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"
|
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}
|
disabled={loadingMeta}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
</div>
|
||||||
Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}).
|
|
||||||
|
{/* 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vote-Lead */}
|
{(voteOpenDateStr && voteOpenTimeStr && matchDateStr && matchTimeStr) &&
|
||||||
<div>
|
new Date(combineLocalDateTime(voteOpenDateStr, voteOpenTimeStr)).getTime() >
|
||||||
<label className="block text-sm font-medium mb-1">Map-Vote lead (Minuten)</label>
|
new Date(combineLocalDateTime(matchDateStr, matchTimeStr)).getTime() && (
|
||||||
<input
|
<Alert type="soft" color="warning" className="col-span-2">
|
||||||
type="number"
|
Der Map-Vote darf nicht <b>nach</b> dem Matchstart liegen.
|
||||||
min={0}
|
</Alert>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Best Of */}
|
{/* Best Of */}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
------------------------------------------------------------------- */
|
------------------------------------------------------------------- */
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import {
|
import {
|
||||||
DndContext, closestCenter, DragOverlay,
|
DndContext, closestCenter, DragOverlay,
|
||||||
@ -69,6 +69,9 @@ export default function EditMatchPlayersModal (props: Props) {
|
|||||||
const otherInit = side === 'A' ? initialB : initialA
|
const otherInit = side === 'A' ? initialB : initialA
|
||||||
const myInit = side === 'A' ? initialA : initialB
|
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 ------------------------ */
|
/* ---- Komplett-Spielerliste laden ------------------------ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return
|
if (!show) return
|
||||||
@ -97,18 +100,20 @@ export default function EditMatchPlayersModal (props: Props) {
|
|||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
// 👉 Hier brauchst du KEIN Normalizer mehr, wenn deine /api/team-Route
|
const allRaw: Player[] = [
|
||||||
// (wie zuletzt angepasst) bereits Player-Objekte liefert.
|
|
||||||
const all = [
|
|
||||||
...(data.activePlayers ?? []),
|
...(data.activePlayers ?? []),
|
||||||
...(data.inactivePlayers ?? []),
|
...(data.inactivePlayers ?? []),
|
||||||
]
|
]
|
||||||
.filter((p: Player) => !!p?.steamId)
|
.filter((p: Player) => !!p?.steamId)
|
||||||
.filter((p: Player, i: number, arr: Player[]) => arr.findIndex(x => x.steamId === p.steamId) === i)
|
.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 || ''))
|
.sort((a: Player, b: Player) => (a.name || '').localeCompare(b.name || ''))
|
||||||
|
|
||||||
setPlayers(all)
|
setPlayers(all)
|
||||||
setSelected(myInit) // initiale Auswahl aus Props
|
setSelected(myInit.filter(id => !otherInitSet.has(id)))
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[EditMatchPlayersModal] load error:', e)
|
console.error('[EditMatchPlayersModal] load error:', e)
|
||||||
|
|||||||
@ -73,24 +73,45 @@ export default function GameBanner(props: Props) {
|
|||||||
const tGameBanner = useTranslations('game-banner')
|
const tGameBanner = useTranslations('game-banner')
|
||||||
|
|
||||||
const phaseStr = String(phase ?? 'unknown').toLowerCase()
|
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(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!show || !el) { // guard
|
if (!rendered || !el) { setBannerPx(0); return }
|
||||||
setBannerPx(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const report = () => setBannerPx(el.getBoundingClientRect().height)
|
const report = () => setBannerPx(el.getBoundingClientRect().height)
|
||||||
report()
|
report()
|
||||||
const ro = new ResizeObserver(report)
|
const ro = new ResizeObserver(report)
|
||||||
ro.observe(el)
|
ro.observe(el)
|
||||||
return () => { ro.disconnect(); setBannerPx(0) }
|
return () => { ro.disconnect(); setBannerPx(0) }
|
||||||
}, [show, setBannerPx])
|
}, [rendered, setBannerPx])
|
||||||
|
|
||||||
// ab hier darfst du bedingt rendern
|
// 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 outerBase = inline ? '' : 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
|
||||||
const outerStyle = inline ? undefined : ({ zIndex } as React.CSSProperties)
|
const outerStyle = inline ? undefined : ({ zIndex } as React.CSSProperties)
|
||||||
@ -122,9 +143,14 @@ export default function GameBanner(props: Props) {
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<div className={outerBase} style={outerStyle} ref={ref}>
|
<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) */}
|
{/* Hintergrundbild (Map) */}
|
||||||
{bgUrl && (
|
{bgUrl && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// InvitePlayersModal.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo, useTransition } from 'react'
|
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 gridRef = useRef<HTMLDivElement>(null)
|
||||||
const firstCardRef = useRef<HTMLDivElement>(null)
|
const firstCardRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const showControls = !isSuccess
|
||||||
|
|
||||||
// aktuelle Grid-Höhe halten
|
// aktuelle Grid-Höhe halten
|
||||||
const [gridHoldHeight, setGridHoldHeight] = useState<number>(0)
|
const [gridHoldHeight, setGridHoldHeight] = useState<number>(0)
|
||||||
|
|
||||||
@ -211,8 +215,15 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
try { json = await res.clone().json() } catch {}
|
try { json = await res.clone().json() } catch {}
|
||||||
|
|
||||||
let results: { steamId: string; ok: boolean }[] = []
|
let results: { steamId: string; ok: boolean }[] = []
|
||||||
|
|
||||||
|
if (directAdd) {
|
||||||
if (json?.results && Array.isArray(json.results)) {
|
if (json?.results && Array.isArray(json.results)) {
|
||||||
results = json.results.map((r: any) => ({ steamId: String(r.steamId), ok: !!r.ok }))
|
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)) {
|
} else if (Array.isArray(json?.invitationIds)) {
|
||||||
const okSet = new Set<string>(json.invitationIds)
|
const okSet = new Set<string>(json.invitationIds)
|
||||||
results = ids.map(id => ({ steamId: id, ok: okSet.has(id) }))
|
results = ids.map(id => ({ steamId: id, ok: okSet.has(id) }))
|
||||||
@ -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])
|
useEffect(() => { setCurrentPage(1) }, [searchTerm])
|
||||||
|
|
||||||
const filteredUsers = useMemo(
|
const filteredUsers = useMemo(() => {
|
||||||
() => allUsers.filter(u => u.name?.toLowerCase().includes(searchTerm.toLowerCase())),
|
if (isSuccess) return allUsers
|
||||||
[allUsers, searchTerm]
|
return allUsers.filter(u => u.name?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
)
|
}, [allUsers, searchTerm, isSuccess])
|
||||||
|
|
||||||
const unselectedUsers = filteredUsers.filter(user =>
|
const unselectedUsers = useMemo(() => {
|
||||||
|
if (isSuccess) return filteredUsers
|
||||||
|
return filteredUsers.filter(user =>
|
||||||
!selectedIds.includes(user.steamId) &&
|
!selectedIds.includes(user.steamId) &&
|
||||||
(!isSuccess || !invitedIds.includes(user.steamId))
|
(!isSuccess || !invitedIds.includes(user.steamId))
|
||||||
)
|
)
|
||||||
|
}, [filteredUsers, selectedIds, invitedIds, isSuccess])
|
||||||
|
|
||||||
const totalPages = Math.ceil(unselectedUsers.length / Math.max(1, usersPerPage))
|
const totalPages = Math.ceil(unselectedUsers.length / Math.max(1, usersPerPage))
|
||||||
const startIdx = (currentPage - 1) * Math.max(1, usersPerPage)
|
const startIdx = (currentPage - 1) * Math.max(1, usersPerPage)
|
||||||
@ -353,15 +355,17 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
closeButtonLoading={!isSuccess && isInviting}
|
closeButtonLoading={!isSuccess && isInviting}
|
||||||
scrollBody
|
scrollBody
|
||||||
>
|
>
|
||||||
|
{showControls && (
|
||||||
<p ref={descRef} className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
|
<p ref={descRef} className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
|
||||||
{directAdd
|
{directAdd
|
||||||
? 'Wähle Spieler aus, die du direkt zum Team hinzufügen möchtest:'
|
? '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:'}
|
: 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filterleiste */}
|
{/* Filterleiste */}
|
||||||
|
{showControls && (
|
||||||
<div ref={searchRef} className="mt-1 grid grid-cols-[auto_1fr] items-center gap-x-3">
|
<div ref={searchRef} className="mt-1 grid grid-cols-[auto_1fr] items-center gap-x-3">
|
||||||
{/* kompakteres Suchfeld */}
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Suchen..."
|
placeholder="Suchen..."
|
||||||
@ -371,8 +375,6 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
focus:outline-none focus:ring focus:ring-blue-400
|
focus:outline-none focus:ring focus:ring-blue-400
|
||||||
dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
|
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]">
|
<div className="justify-self-end min-w-[240px] sm:min-w-[300px]">
|
||||||
<Switch
|
<Switch
|
||||||
id="only-free-switch"
|
id="only-free-switch"
|
||||||
@ -383,6 +385,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ausgewählte */}
|
{/* Ausgewählte */}
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
|
|||||||
@ -39,6 +39,111 @@ const fmtCountdown = (ms: number) => {
|
|||||||
return `${h}:${pad(m)}:${pad(s)}`
|
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 =================== */
|
/* =================== Component =================== */
|
||||||
|
|
||||||
export default function MapVotePanel({ match }: Props) {
|
export default function MapVotePanel({ match }: Props) {
|
||||||
@ -425,6 +530,13 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
teamRightKey = 'teamA'
|
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 teamLeft = (match as any)[teamLeftKey]
|
||||||
const teamRight = (match as any)[teamRightKey]
|
const teamRight = (match as any)[teamRightKey]
|
||||||
const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB
|
const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB
|
||||||
@ -716,13 +828,21 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
|
|
||||||
{/* Hauptbereich */}
|
{/* Hauptbereich */}
|
||||||
{state && (
|
{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) */}
|
{/* Linke Spalte (immer dein Team) */}
|
||||||
<div
|
<div
|
||||||
className={[
|
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
|
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',
|
: 'bg-transparent shadow-none',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
@ -758,7 +878,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
|
|
||||||
{/* Mitte – Mappool / Winrate per Tabs */}
|
{/* Mitte – Mappool / Winrate per Tabs */}
|
||||||
{tab === 'pool' ? (
|
{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">
|
<ul className="flex flex-col gap-3">
|
||||||
{sortedMapPool.map((map) => {
|
{sortedMapPool.map((map) => {
|
||||||
const decision = decisionByMap.get(map)
|
const decision = decisionByMap.get(map)
|
||||||
@ -891,29 +1011,36 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
// Winrate-Tab
|
// Winrate-Tab
|
||||||
<div className="flex-1 min-h-0 grid place-items-center">
|
<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
|
<Chart
|
||||||
type="radar"
|
type="radar"
|
||||||
labels={activeMapLabels}
|
labels={activeMapLabels}
|
||||||
height="auto"
|
height="auto"
|
||||||
datasets={[
|
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',
|
||||||
{ label: teamRight?.name ?? 'Team Rechts', data: teamRadarRight,
|
data: teamRadarRight,
|
||||||
borderColor: 'rgba(255,99,132,0.9)', backgroundColor: 'rgba(255,99,132,0.20)', borderWidth: 2 },
|
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`)}
|
radarIcons={activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`)}
|
||||||
radarIconSize={32}
|
radarIconSize={40}
|
||||||
radarIconOffset={20}
|
|
||||||
radarHideTicks={true}
|
radarHideTicks={true}
|
||||||
// ⬇️ Mapnamen unter den Icons zeigen
|
|
||||||
radarIconLabels={true}
|
radarIconLabels={true}
|
||||||
radarIconLabelFont="12px Inter, system-ui, sans-serif"
|
radarIconLabelFont="12px Inter, system-ui, sans-serif"
|
||||||
radarIconLabelColor="#ffffff"
|
radarIconLabelColor="#ffffff"
|
||||||
radarIconLabelMargin={4}
|
|
||||||
radarMax={120}
|
radarMax={120}
|
||||||
radarStepSize={20}
|
radarStepSize={20}
|
||||||
radarAddRingOffset={false} // <- aus, wenn du echte % zeigst
|
radarAddRingOffset={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -922,9 +1049,10 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
{/* Rechte Spalte (Gegner) */}
|
{/* Rechte Spalte (Gegner) */}
|
||||||
<div
|
<div
|
||||||
className={[
|
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
|
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',
|
: 'bg-transparent shadow-none',
|
||||||
].join(' ')}
|
].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>
|
<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}>
|
<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>
|
</div>
|
||||||
{typeof onPromote === 'function' && (
|
{typeof onPromote === 'function' && (
|
||||||
<div className="pointer-events-auto" onPointerDown={stopDrag}>
|
<div className="pointer-events-auto" onPointerDown={stopDrag}>
|
||||||
@ -154,7 +154,7 @@ export default function MiniCard({
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@ -115,10 +115,7 @@ export default function Modal({
|
|||||||
border border-gray-200 dark:border-neutral-700
|
border border-gray-200 dark:border-neutral-700
|
||||||
shadow-2xs dark:shadow-neutral-700/70
|
shadow-2xs dark:shadow-neutral-700/70
|
||||||
rounded-xl
|
rounded-xl
|
||||||
/* -> Höhe des Panels auf den Viewport begrenzen */
|
|
||||||
max-h-[calc(100vh-56px)]
|
max-h-[calc(100vh-56px)]
|
||||||
/* -> Fix: Verhindert Höhen-Jumps bei Filterwechsel */
|
|
||||||
min-h-[620px]
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{/* Header (fixe Höhe) */}
|
{/* Header (fixe Höhe) */}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// TeamMemberView.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
@ -83,6 +85,12 @@ export default function TeamMemberView({
|
|||||||
const [editedName, setEditedName] = useState(team.name || '')
|
const [editedName, setEditedName] = useState(team.name || '')
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
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
|
// Cache-Busting fürs Logo
|
||||||
const initialLogoVersion =
|
const initialLogoVersion =
|
||||||
(team as any).logoUpdatedAt
|
(team as any).logoUpdatedAt
|
||||||
@ -750,10 +758,7 @@ export default function TeamMemberView({
|
|||||||
<MiniCardDummy
|
<MiniCardDummy
|
||||||
zoneId="inactive"
|
zoneId="inactive"
|
||||||
title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
|
title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
|
||||||
onClick={() => {
|
onClick={openInvite}
|
||||||
setShowInviteModal(false)
|
|
||||||
setTimeout(() => setShowInviteModal(true), 0)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center w-16 h-16 bg-white rounded-full text-black">
|
<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">
|
<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 && (
|
{canInvite && (
|
||||||
<InvitePlayersModal
|
<InvitePlayersModal
|
||||||
|
key={inviteKey}
|
||||||
show={showInviteModal}
|
show={showInviteModal}
|
||||||
onClose={() => setShowInviteModal(false)}
|
onClose={() => setShowInviteModal(false)}
|
||||||
onSuccess={() => {}}
|
onSuccess={() => {}}
|
||||||
@ -844,9 +850,10 @@ export default function TeamMemberView({
|
|||||||
)}
|
)}
|
||||||
{canAddDirect && (
|
{canAddDirect && (
|
||||||
<InvitePlayersModal
|
<InvitePlayersModal
|
||||||
|
key={inviteKey}
|
||||||
show={showInviteModal}
|
show={showInviteModal}
|
||||||
onClose={() => setShowInviteModal(false)}
|
onClose={() => setShowInviteModal(false)}
|
||||||
onSuccess={() => {}}
|
onSuccess={() => setShowInviteModal(false)}
|
||||||
team={team}
|
team={team}
|
||||||
directAdd
|
directAdd
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -48,5 +48,8 @@ export async function POST(req: NextRequest) {
|
|||||||
teamId,
|
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 { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
|
|||||||
@ -377,7 +377,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -391,7 +391,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
|
|||||||
@ -378,7 +378,7 @@ const config = {
|
|||||||
"value": "prisma-client-js"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -392,7 +392,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"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"
|
"value": "prisma-client-js"
|
||||||
},
|
},
|
||||||
"output": {
|
"output": {
|
||||||
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
|
||||||
"fromEnvVar": null
|
"fromEnvVar": null
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
@ -391,7 +391,7 @@ const config = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previewFeatures": [],
|
"previewFeatures": [],
|
||||||
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
|
||||||
"isCustomOutput": true
|
"isCustomOutput": true
|
||||||
},
|
},
|
||||||
"relativeEnvPaths": {
|
"relativeEnvPaths": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user