updated mapvote

This commit is contained in:
Linrador 2025-10-09 14:55:04 +02:00
parent ba69e99120
commit c4c714e5ca
36 changed files with 536 additions and 264 deletions

109
package-lock.json generated
View File

@ -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.3", "@prisma/client": "^6.17.0",
"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",
@ -60,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.3", "prisma": "^6.17.0",
"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",
@ -85,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"
}, },
@ -122,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",
@ -1577,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"
} }
@ -1597,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.3", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.0.tgz",
"integrity": "sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==", "integrity": "sha512-b42mTLOdLEZ6e/igu8CLdccAUX9AwHknQQ1+pHOftnzDP2QoyZyFvcANqSLs5ockimFKJnV7Ljf+qrhNYf6oAg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -1619,9 +1620,9 @@
} }
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "6.16.3", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz",
"integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==", "integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -1632,53 +1633,53 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "6.16.3", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz",
"integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==", "integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.16.3", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz",
"integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==", "integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.16.3", "@prisma/debug": "6.17.0",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"@prisma/fetch-engine": "6.16.3", "@prisma/fetch-engine": "6.17.0",
"@prisma/get-platform": "6.16.3" "@prisma/get-platform": "6.17.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", "version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz",
"integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==", "integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==",
"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.3", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz",
"integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==", "integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.16.3", "@prisma/debug": "6.17.0",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"@prisma/get-platform": "6.16.3" "@prisma/get-platform": "6.17.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "6.16.3", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz",
"integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==", "integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.16.3" "@prisma/debug": "6.17.0"
} }
}, },
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
@ -2067,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"
} }
@ -2085,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"
} }
@ -2183,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",
@ -2617,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"
}, },
@ -3286,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"
} }
@ -3426,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"
@ -3883,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",
@ -4058,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",
@ -5407,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"
} }
@ -5835,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"
}, },
@ -6022,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",
@ -6315,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",
@ -6332,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"
} }
@ -6460,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"
} }
@ -6746,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"
}, },
@ -6776,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.3", "version": "6.17.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz",
"integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==", "integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "6.16.3", "@prisma/config": "6.17.0",
"@prisma/engines": "6.16.3" "@prisma/engines": "6.17.0"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"
@ -6902,7 +6902,6 @@
"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"
} }
@ -6912,7 +6911,6 @@
"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"
}, },
@ -6982,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",
@ -7639,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",
@ -7697,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"
}, },
@ -7923,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"
@ -8179,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",

View File

@ -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.3", "@prisma/client": "^6.17.0",
"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",
@ -66,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.3", "prisma": "^6.17.0",
"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",

View File

@ -11,7 +11,6 @@ export default function AdminPage() {
const activeTab: 'teams' | 'matches' | 'privacy' | '' = const activeTab: 'teams' | 'matches' | 'privacy' | '' =
pathname.startsWith('/admin/teams') ? 'teams' : pathname.startsWith('/admin/teams') ? 'teams' :
pathname.startsWith('/admin/matches') ? 'matches' : pathname.startsWith('/admin/matches') ? 'matches' :
pathname.startsWith('/admin/privacy') ? 'privacy' :
'' ''
switch (activeTab) { switch (activeTab) {
@ -22,12 +21,6 @@ export default function AdminPage() {
</Card> </Card>
) )
case 'privacy':
return (
<Card title="Datenschutz"
description="Einstellungen zum Schutz deiner Daten." />
)
case 'teams': case 'teams':
return ( return (
<Card title="Teams" <Card title="Teams"

View File

@ -6,7 +6,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="container mx-auto"> <div className="container mx-auto">
<Tabs> <Tabs>
<Tab name="Spielpläne" href="/admin/matches" /> <Tab name="Spielpläne" href="/admin/matches" />
<Tab name="Privacy" href="/admin/privacy" />
<Tab name="Teams" href="/admin/teams" /> <Tab name="Teams" href="/admin/teams" />
<Tab name="Serververwaltung" href="/admin/server" /> <Tab name="Serververwaltung" href="/admin/server" />
</Tabs> </Tabs>

View File

@ -1,10 +1,10 @@
// src/app/components/GameBanner.tsx // src/app/[locale]/components/GameBanner.tsx
'use client' 'use client'
import React, {useEffect, useRef, useState} from 'react' import React, {useEffect, useRef, useState} from 'react'
import Link from 'next/link' import Link from 'next/link'
import Button from './Button' import Button from './Button'
import {useUiChromeStore} from '@/lib/useUiChromeStore' import { useGameBannerStore } from '@/lib/useGameBannerStore'
import {MAP_OPTIONS} from '@/lib/mapOptions' import {MAP_OPTIONS} from '@/lib/mapOptions'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
@ -26,6 +26,8 @@ type Props = {
inline?: boolean inline?: boolean
connectUri?: string connectUri?: string
missingCount?: number missingCount?: number
bgUrl?: string
iconUrl?: string
} }
/* ---------- helpers ---------- */ /* ---------- helpers ---------- */
@ -70,7 +72,7 @@ export default function GameBanner(props: Props) {
} = props } = props
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement | null>(null)
const setBannerPx = useUiChromeStore(s => s.setGameBannerPx) const setBannerPx = useGameBannerStore(s => s.setGameBannerPx)
const isSmDown = useIsSmDown() const isSmDown = useIsSmDown()
const tGameBanner = useTranslations('game-banner') const tGameBanner = useTranslations('game-banner')
@ -125,8 +127,9 @@ export default function GameBanner(props: Props) {
? 'bg-emerald-700/95 text-white ring-1 ring-black/10' ? 'bg-emerald-700/95 text-white ring-1 ring-black/10'
: 'bg-amber-700/95 text-white ring-1 ring-black/10' : 'bg-amber-700/95 text-white ring-1 ring-black/10'
const bgUrl = pickMapImageFromOptions(mapKey) const bgUrl = props.bgUrl ?? pickMapImageFromOptions(mapKey)
const iconUrl = isConnected ? (pickMapIcon(mapKey) ?? '') : '/assets/img/icons/ui/disconnect.svg' const iconUrl = props.iconUrl ?? (isConnected ? (pickMapIcon(mapKey) ?? '') : '/assets/img/icons/ui/disconnect.svg')
const pretty = { const pretty = {
map: mapLabel ?? mapKey ?? '—', map: mapLabel ?? mapKey ?? '—',

View File

@ -0,0 +1,35 @@
// /src/app/[locale]/components/GameBannerHost.tsx
'use client'
import GameBanner from './GameBanner'
import { useGameBannerStore } from '@/lib/useGameBannerStore'
export default function GameBannerHost() {
const banner = useGameBannerStore(s => s.gameBanner)
const setBanner = useGameBannerStore(s => s.setGameBanner)
const patch = useGameBannerStore(s => s.patchGameBanner)
if (!banner || !banner.visible) return null
return (
<GameBanner
variant={banner.variant ?? 'disconnected'}
visible={true}
zIndex={banner.zIndex ?? 9999}
inline={banner.inline ?? false}
serverLabel={banner.serverLabel}
mapKey={banner.mapKey}
mapLabel={banner.mapLabel}
bgUrl={banner.bgUrl}
iconUrl={banner.iconUrl}
connectUri={banner.connectUri}
phase={banner.phase ?? 'lobby'}
score={banner.score ?? ' : '}
connectedCount={banner.connectedCount ?? 0}
totalExpected={banner.totalExpected ?? 10}
missingCount={banner.missingCount ?? 0}
onReconnect={() => patch({ variant: 'connected' })}
onDisconnect={() => setBanner(null)}
/>
)
}

View File

@ -1,8 +1,8 @@
// src/app/components/GameBannerSpacer.tsx // /src/app/[locale]/components/GameBannerSpacer.tsx
'use client' 'use client'
import {useUiChromeStore} from '@/lib/useUiChromeStore' import { useGameBannerStore } from '@/lib/useGameBannerStore'
export default function GameBannerSpacer({ className = '' }: { className?: string }) { export default function GameBannerSpacer({ className = '' }: { className?: string }) {
const bannerPx = useUiChromeStore(s => s.gameBannerPx) const bannerPx = useGameBannerStore(s => s.gameBannerPx)
return <div className={className} style={{ height: bannerPx ?? 0 }} aria-hidden /> return <div className={className} style={{ height: bannerPx ?? 0 }} aria-hidden />
} }

View File

@ -18,6 +18,7 @@ import type { MapVoteState } from '../../../types/mapvote'
import { MAP_OPTIONS } from '@/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import { Tabs } from './Tabs' import { Tabs } from './Tabs'
import Chart from './Chart' import Chart from './Chart'
import { MAPVOTE_REFRESH } from '@/lib/sseEvents'
/* =================== Utilities & constants =================== */ /* =================== Utilities & constants =================== */
@ -189,32 +190,6 @@ export default function MapVotePanel({ match }: Props) {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => {
if (!lastEvent) return
const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e
const evt = unwrap(lastEvent)
const type = lastEvent.type ?? evt?.type
if (evt?.matchId !== match.id) return
if (type === 'map-vote-updated') {
const { opensAt, leadMinutes } = evt ?? {}
if (opensAt) {
const ts = new Date(opensAt).getTime()
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts)
setState(prev => (prev ? { ...prev, opensAt } : prev))
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) {
const ts = matchBaseTs - Number(leadMinutes) * 60_000
setOpensAtOverrideTs(ts)
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev))
}
}
const MAPVOTE_REFRESH = new Set(['map-vote-updated', 'map-vote-reset', 'map-vote-admin-edit'])
if (MAPVOTE_REFRESH.has(type)) load()
}, [lastEvent, match.id, load, matchBaseTs])
/* -------- Admin-Edit Mirror -------- */ /* -------- Admin-Edit Mirror -------- */
const adminEditingBy = state?.adminEdit?.by ?? null const adminEditingBy = state?.adminEdit?.by ?? null
const adminEditingEnabled = !!state?.adminEdit?.enabled const adminEditingEnabled = !!state?.adminEdit?.enabled
@ -227,8 +202,10 @@ export default function MapVotePanel({ match }: Props) {
const me = session?.user const me = session?.user
const isAdmin = !!me?.isAdmin const isAdmin = !!me?.isAdmin
const mySteamId = me?.steamId const mySteamId = me?.steamId
const isLeaderA = !!mySteamId && match.teamA?.leader?.steamId === mySteamId const leaderAId = state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId ?? null
const isLeaderB = !!mySteamId && match.teamB?.leader?.steamId === mySteamId const leaderBId = state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId ?? null
const isLeaderA = !!mySteamId && leaderAId === mySteamId
const isLeaderB = !!mySteamId && leaderBId === mySteamId
const isFrozenByAdmin = adminEditingEnabled && adminEditingBy !== mySteamId const isFrozenByAdmin = adminEditingEnabled && adminEditingBy !== mySteamId
@ -457,6 +434,62 @@ export default function MapVotePanel({ match }: Props) {
[playersB] [playersB]
) )
// ⬇️ Ergänzen: Hilfsmenge aller Spieler-IDs dieses Matches
const teamSteamIds = useMemo(
() => new Set([
...playersA.map(p => p.user.steamId),
...playersB.map(p => p.user.steamId),
]),
[playersA, playersB]
)
// Optimistisches Update: Leader im lokalen state tauschen
const applyLeaderChange = useCallback(
(teamIdFromEvt: string | null, newLeaderSteamId: string | null) => {
if (!state || !newLeaderSteamId) return;
// herausfinden, welches Team gemeint ist (per teamId oder über Players)
const teamKey: 'teamA' | 'teamB' | null =
teamIdFromEvt
? (state?.teams?.teamA?.id === teamIdFromEvt
? 'teamA'
: state?.teams?.teamB?.id === teamIdFromEvt
? 'teamB'
: null)
: (playersA.some(p => p.user.steamId === newLeaderSteamId)
? 'teamA'
: playersB.some(p => p.user.steamId === newLeaderSteamId)
? 'teamB'
: null);
if (!teamKey) return;
setState(prev => {
if (!prev) return prev;
const oldId = prev.teams?.[teamKey]?.leader?.steamId;
if (oldId === newLeaderSteamId) return prev; // nichts zu tun
const poolPlayers = prev.teams?.[teamKey]?.players ?? [];
const found = poolPlayers.find(p => p.steamId === newLeaderSteamId);
const leader = found
? { steamId: found.steamId, name: found.name, avatar: found.avatar }
: { steamId: newLeaderSteamId };
return {
...prev,
teams: {
...prev.teams,
[teamKey]: {
...prev.teams?.[teamKey],
leader,
},
},
} as MapVoteState;
});
},
[state, playersA, playersB, setState]
);
const amInTeamA = !!mySteamId && playersA.some(p => p.user?.steamId === mySteamId) const amInTeamA = !!mySteamId && playersA.some(p => p.user?.steamId === mySteamId)
const amInTeamB = !!mySteamId && playersB.some(p => p.user?.steamId === mySteamId) const amInTeamB = !!mySteamId && playersB.some(p => p.user?.steamId === mySteamId)
const myTeamId = amInTeamA ? match.teamA?.id : amInTeamB ? match.teamB?.id : null const myTeamId = amInTeamA ? match.teamA?.id : amInTeamB ? match.teamB?.id : null
@ -601,6 +634,96 @@ export default function MapVotePanel({ match }: Props) {
[mapsKey] // reicht, solange keys unverändert [mapsKey] // reicht, solange keys unverändert
) )
useEffect(() => {
if (!lastEvent) return
const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e
const evt = unwrap(lastEvent)
const type = lastEvent.type ?? evt?.type
const evtMatchId: string | null = evt?.matchId ?? null
const evtTeamId : string | null = evt?.teamId ?? null
const actionType: string | null = evt?.actionType ?? null
const actionData: string | null = evt?.actionData ?? null // z.B. newLeaderSteamId
// 1) Relevanz wie bisher
const isForThisMatchByMatchId =
!!evtMatchId && evtMatchId === match.id
const isForThisMatchByTeamId =
!!evtTeamId && (evtTeamId === match.teamA?.id || evtTeamId === match.teamB?.id)
// 2) Notifications abdecken (changed + self)
const isLeaderChangeNotification =
type === 'notification' && (actionType === 'team-leader-changed' || actionType === 'team-leader-self')
// 3) Gehört der neue Leader zu unseren Teams?
const byNewLeaderId =
isLeaderChangeNotification && !!actionData && teamSteamIds.has(actionData)
// 4) Relevanz
const isRelevant = isForThisMatchByMatchId || isForThisMatchByTeamId || byNewLeaderId
// 5) Offensichtliche Leader-Änderungen -> hart refreshen, auch ohne Relevanzbeweis
const forceRefresh =
type === 'team-leader-changed' ||
type === 'team-updated' ||
isLeaderChangeNotification
if (!isRelevant && !forceRefresh) return
// map-vote-updated: opensAt-Override wie gehabt ...
if (type === 'map-vote-updated') {
const { opensAt, leadMinutes } = evt ?? {}
if (opensAt) {
const ts = new Date(opensAt).getTime()
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts)
setState(prev => (prev ? { ...prev, opensAt } : prev))
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) {
const ts = matchBaseTs - Number(leadMinutes) * 60_000
setOpensAtOverrideTs(ts)
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev))
}
}
// --- Leader-Events zuerst lokal anwenden (ohne Reload) ----------------------
const isLeaderChangeEvent =
type === 'team-leader-changed' ||
(type === 'notification' &&
(actionType === 'team-leader-changed' || actionType === 'team-leader-self'));
if (isLeaderChangeEvent) {
applyLeaderChange(evtTeamId ?? null, actionData ?? null);
return; // kein load()
}
// --- map-vote-/sonstige Events wie gehabt -----------------------------------
let shouldRefresh = MAPVOTE_REFRESH.has(type);
// Optional: harte Reloads weiter einschränken, falls gewünscht
// (z.B. kein Reload für "team-updated", wenn dich nur Leader interessiert)
// if (type === 'team-updated') shouldRefresh = false;
if (type === 'map-vote-updated') {
const { opensAt, leadMinutes } = evt ?? {};
if (opensAt) {
const ts = new Date(opensAt).getTime();
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts);
setState(prev => (prev ? { ...prev, opensAt } : prev));
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) {
const ts = matchBaseTs - Number(leadMinutes) * 60_000;
setOpensAtOverrideTs(ts);
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev));
}
}
if (shouldRefresh) {
load();
}
}, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, load, matchBaseTs, teamSteamIds])
// Effect NUR an stabile Keys + Tab hängen // Effect NUR an stabile Keys + Tab hängen
useEffect(() => { useEffect(() => {
if (tab !== 'winrate') return; if (tab !== 'winrate') return;

View File

@ -25,6 +25,7 @@ import Link from 'next/link'
import Card from './Card' import Card from './Card'
import MiniPlayerCard from './MiniPlayerCard' import MiniPlayerCard from './MiniPlayerCard'
import type { PlayerSummary } from './MiniPlayerCard' import type { PlayerSummary } from './MiniPlayerCard'
import UserAvatarWithStatus from './UserAvatarWithStatus'
// Für den Prefetch greifen wir dieselbe Performance-Logik auf: // Für den Prefetch greifen wir dieselbe Performance-Logik auf:
const KD_CAP = 2.0 const KD_CAP = 2.0
@ -623,7 +624,8 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
players: MatchPlayer[], players: MatchPlayer[],
teamTitle: string, teamTitle: string,
showEdit: boolean, showEdit: boolean,
onEditClick?: () => void onEditClick?: () => void,
leaderSteamId?: string | null,
) => { ) => {
const sorted = [...players].sort( const sorted = [...players].sort(
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0), (a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
@ -672,6 +674,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
{sorted.map((p) => { {sorted.map((p) => {
const banned = isPlayerBanned(p); const banned = isPlayerBanned(p);
const title = banned ? banTooltip(p) : undefined; const title = banned ? banTooltip(p) : undefined;
const isLeader = p.user.steamId === leaderSteamId // ⬅️ hier prüfen
return ( return (
<Table.Row key={p.user.steamId} title={title} className={`${banned ? 'bg-red-900/20' : ''}`}> <Table.Row key={p.user.steamId} title={title} className={`${banned ? 'bg-red-900/20' : ''}`}>
@ -692,10 +695,15 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
}} }}
onClick={() => router.push(`/profile/${p.user.steamId}`)} onClick={() => router.push(`/profile/${p.user.steamId}`)}
> >
<img <UserAvatarWithStatus
steamId={p.user.steamId}
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'} src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name} alt={p.user.name}
size={36}
isLeader={isLeader}
showStatus={false}
className="mr-3 h-8 w-8 rounded-full" className="mr-3 h-8 w-8 rounded-full"
alignRight={false}
/> />
<div className="text-base font-semibold flex items-center gap-2"> <div className="text-base font-semibold flex items-center gap-2">
<span>{p.user.name ?? 'Unbekannt'}</span> <span>{p.user.name ?? 'Unbekannt'}</span>
@ -963,12 +971,12 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
{/* Teams / Tabellen */} {/* Teams / Tabellen */}
{/* Team A */} {/* Team A */}
<div> <div>
{renderTable(teamAPlayers, teamATitle, showEditA, () => setEditSide('A'))} {renderTable(teamAPlayers, teamATitle, showEditA, () => setEditSide('A'), match.teamA?.leader?.steamId)}
</div> </div>
{/* Team B */} {/* Team B */}
<div> <div>
{renderTable(teamBPlayers, teamBTitle, showEditB, () => setEditSide('B'))} {renderTable(teamBPlayers, teamBTitle, showEditB, () => setEditSide('B'), match.teamB?.leader?.steamId)}
</div> </div>
{/* Echte Modals (außerhalb IIFE, State oben): */} {/* Echte Modals (außerhalb IIFE, State oben): */}

View File

@ -79,6 +79,7 @@ export default function MiniCard({
const cardClasses = ` const cardClasses = `
relative flex flex-col items-center p-4 border rounded-lg transition relative flex flex-col items-center p-4 border rounded-lg transition
max-h-[200px] max-w-[160px] overflow-hidden shadow-2xs rounded-xl max-h-[200px] max-w-[160px] overflow-hidden shadow-2xs rounded-xl
hover:bg-neutral-400 hover:dark:bg-neutral-700 hover:shadow-md
${statusBg} ${baseBorder} ${statusBg} ${baseBorder}
${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''} ${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''}
${isSelectable ? 'cursor-pointer' : ''} ${isSelectable ? 'cursor-pointer' : ''}

View File

@ -8,7 +8,7 @@ import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { NOTIFICATION_EVENTS, isSseEventType } from '@/lib/sseEvents' import { NOTIFICATION_EVENTS, isSseEventType } from '@/lib/sseEvents'
import { useUiChromeStore } from '@/lib/useUiChromeStore' import { useGameBannerStore } from '@/lib/useGameBannerStore'
type Notification = { type Notification = {
id: string id: string
@ -39,7 +39,7 @@ export default function NotificationBell() {
const router = useRouter() const router = useRouter()
const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten const { lastEvent } = useSSEStore() // nur konsumieren, nicht connecten
const bellRef = useRef<HTMLButtonElement | null>(null); const bellRef = useRef<HTMLButtonElement | null>(null);
const telemetryBannerPx = useUiChromeStore(s => s.gameBannerPx) const telemetryBannerPx = useGameBannerStore(s => s.gameBannerPx)
const [notifications, setNotifications] = useState<Notification[]>([]) const [notifications, setNotifications] = useState<Notification[]>([])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)

View File

@ -156,20 +156,42 @@ export default function TelemetrySocket() {
useEffect(() => { useEffect(() => {
aliveRef.current = true aliveRef.current = true
const connect = () => { let wsLocal: WebSocket | null = null // <- dieses WS schließen wir im Cleanup
const connectOnce = () => {
if (!aliveRef.current || !url) return if (!aliveRef.current || !url) return
// ✅ Guard: nicht verbinden, wenn schon OPEN oder CONNECTING
if (wsRef.current && (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING
)) {
return
}
const ws = new WebSocket(url) const ws = new WebSocket(url)
wsLocal = ws
wsRef.current = ws wsRef.current = ws
ws.onopen = () => { ws.onopen = () => {
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open') if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open')
// ✅ evtl. bestehenden Reconnect-Timer löschen
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
} }
ws.onerror = () => { ws.onerror = () => {
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] error') if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] error')
} }
ws.onclose = () => { ws.onclose = () => {
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] closed') if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] closed')
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000) wsRef.current = null
// ✅ nur EINEN Reconnect-Timer setzen
if (aliveRef.current && !retryRef.current) {
retryRef.current = window.setTimeout(() => {
retryRef.current = null
connectOnce()
}, 2000)
}
} }
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
@ -241,13 +263,13 @@ export default function TelemetrySocket() {
} }
} }
connect() connectOnce()
return () => { return () => {
aliveRef.current = false aliveRef.current = false
if (retryRef.current) window.clearTimeout(retryRef.current) if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {} try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
} }
}, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, hideOverlay, mySteamId]) }, [url])
// Wenn die API { matchId: null } liefert → KEIN Banner // Wenn die API { matchId: null } liefert → KEIN Banner

View File

@ -5,10 +5,11 @@ import { useEffect, useState, useMemo } from 'react'
import Image from 'next/image' import Image from 'next/image'
import clsx from 'clsx' import clsx from 'clsx'
import { useSSEStore } from '@/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import type React from 'react'
type Presence = 'online' | 'away' | 'offline' type Presence = 'online' | 'away' | 'offline'
type Props = { type Props = React.HTMLAttributes<HTMLDivElement> & {
steamId?: string | null steamId?: string | null
src: string src: string
alt?: string alt?: string
@ -17,6 +18,8 @@ type Props = {
isLeader?: boolean isLeader?: boolean
alignRight?: boolean alignRight?: boolean
showStatus?: boolean showStatus?: boolean
/** Zusätzliche Klassen für den runden Avatar-/Ring-Container */
avatarClassName?: string
} }
export default function UserAvatarWithStatus({ export default function UserAvatarWithStatus({
@ -28,21 +31,20 @@ export default function UserAvatarWithStatus({
isLeader = false, isLeader = false,
alignRight = false, alignRight = false,
showStatus = true, showStatus = true,
className, // <— NEU: Klassen für den äußeren Wrapper
avatarClassName, // <— NEU: Klassen für den inneren Avatar-Container
...rest // <— NEU: alle weiteren div-Props (onClick, title, …)
}: Props) { }: Props) {
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const [status, setStatus] = useState<Presence>(initialStatus) const [status, setStatus] = useState<Presence>(initialStatus)
// Dynamik für Krone (Skalierung & Offset relativ zu size)
const { crownSize, crownOffset, crownIconSize } = useMemo(() => { const { crownSize, crownOffset, crownIconSize } = useMemo(() => {
// Button-Durchmesser max. 20px
const cs = Math.min(20, Math.round(Math.max(size * 0.5, 14))) const cs = Math.min(20, Math.round(Math.max(size * 0.5, 14)))
const off = Math.round(size * 0.10) // Offset bleibt dynamisch const off = Math.round(size * 0.10)
// Icon etwas kleiner (z.B. 70% vom Button)
const icon = Math.round(cs * 0.70) const icon = Math.round(cs * 0.70)
return { crownSize: cs, crownOffset: off, crownIconSize: icon } return { crownSize: cs, crownOffset: off, crownIconSize: icon }
}, [size]) }, [size])
// initialer Status
useEffect(() => { useEffect(() => {
if (!showStatus || !steamId) return if (!showStatus || !steamId) return
let alive = true let alive = true
@ -57,15 +59,12 @@ export default function UserAvatarWithStatus({
return () => { alive = false } return () => { alive = false }
}, [steamId, showStatus]) }, [steamId, showStatus])
// SSE-Updates
useEffect(() => { useEffect(() => {
if (!showStatus || !steamId) return if (!showStatus || !steamId) return
if (!lastEvent || lastEvent.type !== 'user-status-updated') return if (!lastEvent || lastEvent.type !== 'user-status-updated') return
const raw = lastEvent.payload as any const raw = lastEvent.payload as any
const sid = raw?.steamId ?? raw?.payload?.steamId const sid = raw?.steamId ?? raw?.payload?.steamId
const st = (raw?.status ?? raw?.payload?.status) as Presence | undefined const st = (raw?.status ?? raw?.payload?.status) as Presence | undefined
if (sid === steamId && st) setStatus(st) if (sid === steamId && st) setStatus(st)
}, [lastEvent, steamId, showStatus]) }, [lastEvent, steamId, showStatus])
@ -78,15 +77,17 @@ export default function UserAvatarWithStatus({
return ( return (
<div <div
className="relative items-center justify-center" className={clsx('relative inline-flex items-center justify-center', className)}
style={{ width: size, height: size }} style={{ width: size, height: size }}
{...rest}
> >
<div <div
className={clsx( className={clsx(
'flex items-center justify-center rounded-full transition', 'flex items-center justify-center rounded-full transition',
showStatus showStatus
? ['ring-1 ring-offset-1 ring-offset-white dark:ring-offset-neutral-900', statusRing] ? ['ring-1 ring-offset-1 ring-offset-white dark:ring-offset-neutral-900', statusRing]
: null : null,
avatarClassName
)} )}
style={{ width: size, height: size }} style={{ width: size, height: size }}
> >
@ -99,7 +100,6 @@ export default function UserAvatarWithStatus({
/> />
</div> </div>
{/* Team-Leader-Krone skaliert & positioniert relativ zur Avatargröße */}
{isLeader && ( {isLeader && (
<span <span
className={clsx( className={clsx(
@ -116,7 +116,6 @@ export default function UserAvatarWithStatus({
<svg xmlns="http://www.w3.org/2000/svg" height={crownIconSize} width={crownIconSize} fill="currentColor" viewBox="0 0 640 640"> <svg xmlns="http://www.w3.org/2000/svg" height={crownIconSize} width={crownIconSize} fill="currentColor" viewBox="0 0 640 640">
<path d="M345 151.2C354.2 143.9 360 132.6 360 120C360 97.9 342.1 80 320 80C297.9 80 280 97.9 280 120C280 132.6 285.9 143.9 295 151.2L226.6 258.8C216.6 274.5 195.3 278.4 180.4 267.2L120.9 222.7C125.4 216.3 128 208.4 128 200C128 177.9 110.1 160 88 160C65.9 160 48 177.9 48 200C48 221.8 65.5 239.6 87.2 240L119.8 457.5C124.5 488.8 151.4 512 183.1 512L456.9 512C488.6 512 515.5 488.8 520.2 457.5L552.8 240C574.5 239.6 592 221.8 592 200C592 177.9 574.1 160 552 160C529.9 160 512 177.9 512 200C512 208.4 514.6 216.3 519.1 222.7L459.7 267.3C444.8 278.5 423.5 274.6 413.5 258.9L345 151.2z"/> <path d="M345 151.2C354.2 143.9 360 132.6 360 120C360 97.9 342.1 80 320 80C297.9 80 280 97.9 280 120C280 132.6 285.9 143.9 295 151.2L226.6 258.8C216.6 274.5 195.3 278.4 180.4 267.2L120.9 222.7C125.4 216.3 128 208.4 128 200C128 177.9 110.1 160 88 160C65.9 160 48 177.9 48 200C48 221.8 65.5 239.6 87.2 240L119.8 457.5C124.5 488.8 151.4 512 183.1 512L456.9 512C488.6 512 515.5 488.8 520.2 457.5L552.8 240C574.5 239.6 592 221.8 592 200C592 177.9 574.1 160 552 160C529.9 160 512 177.9 512 200C512 208.4 514.6 216.3 519.1 222.7L459.7 267.3C444.8 278.5 423.5 274.6 413.5 258.9L345 151.2z"/>
</svg> </svg>
</span> </span>
)} )}
</div> </div>

View File

@ -19,6 +19,7 @@ import AudioPrimer from './components/AudioPrimer';
import ReadyOverlayHost from './components/ReadyOverlayHost'; import ReadyOverlayHost from './components/ReadyOverlayHost';
import TelemetrySocket from './components/TelemetrySocket'; import TelemetrySocket from './components/TelemetrySocket';
import GameBannerSpacer from './components/GameBannerSpacer'; import GameBannerSpacer from './components/GameBannerSpacer';
import GameBannerHost from './components/GameBannerHost';
const geistSans = Geist({variable: '--font-geist-sans', subsets: ['latin']}); const geistSans = Geist({variable: '--font-geist-sans', subsets: ['latin']});
const geistMono = Geist_Mono({variable: '--font-geist-mono', subsets: ['latin']}); const geistMono = Geist_Mono({variable: '--font-geist-mono', subsets: ['latin']});
@ -65,6 +66,7 @@ export default async function RootLayout({children, params}: Props) {
<main className="flex-1 min-w-0 min-h-0 overflow-auto"> <main className="flex-1 min-w-0 min-h-0 overflow-auto">
<div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div> <div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div>
</main> </main>
<GameBannerHost />
<GameBannerSpacer className="hidden sm:block" /> <GameBannerSpacer className="hidden sm:block" />
</div> </div>
</div> </div>

View File

@ -6,7 +6,7 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { MapVoteAction } from '@/generated/prisma' import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client' import { sendServerSSEMessage } from '@/lib/sse-server-client'
import { randomInt, createHash, randomBytes } from 'crypto' import { randomInt, createHash } from 'crypto'
import { MAP_OPTIONS } from '@/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@ -469,7 +469,7 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
if (!sLike.locked) return if (!sLike.locked) return
const bestOf = (mLike.bestOf ?? sLike.bestOf ?? (match.matchType === 'community' ? 3 : 1)) const bestOf = (mLike.bestOf ?? sLike.bestOf ?? (match.matchType === 'community' ? 3 : 1))
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
if (chosen.length < bestOf) return if (chosen.length < bestOf) return false
// ⬇️ JSON bauen (enthält cs2MatchId/rndId) // ⬇️ JSON bauen (enthält cs2MatchId/rndId)
const json = buildMatchJson(mLike, sLike) const json = buildMatchJson(mLike, sLike)
@ -511,8 +511,6 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
// … dann das neue laden // … dann das neue laden
await sendServerCommand(`matchzy_loadmatch ${filename}`) await sendServerCommand(`matchzy_loadmatch ${filename}`)
// Spieler persistieren + cs2MatchId speichern wie gehabt
await persistMatchPlayers(match)
if (typeof json.matchid === 'number') { if (typeof json.matchid === 'number') {
await prisma.match.update({ await prisma.match.update({
where: { id: match.id }, where: { id: match.id },
@ -524,8 +522,11 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
data: { exportedAt: new Date() }, data: { exportedAt: new Date() },
}) })
} }
return true
} catch (err) { } catch (err) {
console.error('[mapvote] Export fehlgeschlagen:', err) console.error('[mapvote] Export fehlgeschlagen:', err)
return false
} }
} }
@ -554,6 +555,17 @@ async function writeLiveStateToServerConfig(match: any, vote: any, mapVisuals: R
bannerExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000), bannerExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
}, },
}) })
await sendServerSSEMessage({
type: 'server-config-updated',
payload: {
activeMatchId: match.id,
activeMapKey: key,
activeMapLabel: label,
activeMapBg: bg,
activeParticipants: participants,
},
})
} catch (e) { } catch (e) {
console.warn('[mapvote] writeLiveStateToServerConfig failed:', e) console.warn('[mapvote] writeLiveStateToServerConfig failed:', e)
} }
@ -561,15 +573,20 @@ async function writeLiveStateToServerConfig(match: any, vote: any, mapVisuals: R
/** DRY: Wird in jedem "locked"-Pfad aufgerufen */ /** DRY: Wird in jedem "locked"-Pfad aufgerufen */
async function afterVoteLocked(match: any, vote: any, mapVisuals: Record<string, any>) { async function afterVoteLocked(match: any, vote: any, mapVisuals: Record<string, any>) {
// 1) Spieler für dieses Match festschreiben // 1) Spieler festschreiben
await persistMatchPlayers(match) await persistMatchPlayers(match)
// 2) Live-State für Banner speichern
// 2) Serverexport + Matchzy-Load
const ok = await exportMatchToSftpDirect(match, vote) // ← wartet bis Load versucht wurde
// 3) Nur wenn Export/Load ok war: Live-State + SSE "server-config-updated"
if (ok) {
await writeLiveStateToServerConfig(match, vote, mapVisuals) await writeLiveStateToServerConfig(match, vote, mapVisuals)
// 3) Serverexport + Matchzy-Load }
await exportMatchToSftpDirect(match, vote)
} }
/* ---------- kleine Helfer für match-ready Payload ---------- */ /* ---------- kleine Helfer für match-ready Payload ---------- */
function deriveChosenSteps(vote: any) { function deriveChosenSteps(vote: any) {
@ -697,9 +714,6 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
}); });
await afterVoteLocked(match, updated, mapVisuals) await afterVoteLocked(match, updated, mapVisuals)
// Export serverseitig
await exportMatchToSftpDirect(match, updated)
} }
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams }) return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
@ -762,9 +776,6 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
}); });
await afterVoteLocked(match, updated, mapVisuals) await afterVoteLocked(match, updated, mapVisuals)
// Export serverseitig
await exportMatchToSftpDirect(match, updated)
} }
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams }) return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
@ -865,8 +876,6 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
}); });
await afterVoteLocked(match, updated, mapVisuals) await afterVoteLocked(match, updated, mapVisuals)
await exportMatchToSftpDirect(match, updated)
} }
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams }) return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })

View File

@ -97,7 +97,6 @@ export async function POST(req: NextRequest) {
}))) })))
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-leader-changed', type: 'team-leader-changed',
targetUserIds: others,
teamId, teamId,
message: textForOthers, message: textForOthers,
actionData: newLeaderSteamId, actionData: newLeaderSteamId,

View File

@ -35,12 +35,12 @@ exports.Prisma = Prisma
exports.$Enums = {} exports.$Enums = {}
/** /**
* Prisma Client JS version: 6.16.3 * Prisma Client JS version: 6.17.0
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a * Query Engine version: c0aafc03b8ef6cdced8654b9a817999e02457d6a
*/ */
Prisma.prismaVersion = { Prisma.prismaVersion = {
client: "6.16.3", client: "6.17.0",
engine: "bb420e667c1820a8c05a38023385f6cc7ef8e83a" engine: "c0aafc03b8ef6cdced8654b9a817999e02457d6a"
} }
Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError; Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError;
@ -412,7 +412,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": {
@ -426,7 +426,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": {
@ -434,8 +434,8 @@ const config = {
"schemaEnvPath": "../../../.env" "schemaEnvPath": "../../../.env"
}, },
"relativePath": "../../../prisma", "relativePath": "../../../prisma",
"clientVersion": "6.16.3", "clientVersion": "6.17.0",
"engineVersion": "bb420e667c1820a8c05a38023385f6cc7ef8e83a", "engineVersion": "c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"datasourceNames": [ "datasourceNames": [
"db" "db"
], ],

View File

@ -20,12 +20,12 @@ exports.Prisma = Prisma
exports.$Enums = {} exports.$Enums = {}
/** /**
* Prisma Client JS version: 6.16.3 * Prisma Client JS version: 6.17.0
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a * Query Engine version: c0aafc03b8ef6cdced8654b9a817999e02457d6a
*/ */
Prisma.prismaVersion = { Prisma.prismaVersion = {
client: "6.16.3", client: "6.17.0",
engine: "bb420e667c1820a8c05a38023385f6cc7ef8e83a" engine: "c0aafc03b8ef6cdced8654b9a817999e02457d6a"
} }
Prisma.PrismaClientKnownRequestError = () => { Prisma.PrismaClientKnownRequestError = () => {

View File

@ -487,8 +487,8 @@ export namespace Prisma {
export import Exact = $Public.Exact export import Exact = $Public.Exact
/** /**
* Prisma Client JS version: 6.16.3 * Prisma Client JS version: 6.17.0
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a * Query Engine version: c0aafc03b8ef6cdced8654b9a817999e02457d6a
*/ */
export type PrismaVersion = { export type PrismaVersion = {
client: string client: string

View File

@ -35,12 +35,12 @@ exports.Prisma = Prisma
exports.$Enums = {} exports.$Enums = {}
/** /**
* Prisma Client JS version: 6.16.3 * Prisma Client JS version: 6.17.0
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a * Query Engine version: c0aafc03b8ef6cdced8654b9a817999e02457d6a
*/ */
Prisma.prismaVersion = { Prisma.prismaVersion = {
client: "6.16.3", client: "6.17.0",
engine: "bb420e667c1820a8c05a38023385f6cc7ef8e83a" engine: "c0aafc03b8ef6cdced8654b9a817999e02457d6a"
} }
Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError; Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError;
@ -413,7 +413,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": {
@ -427,7 +427,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": {
@ -435,8 +435,8 @@ const config = {
"schemaEnvPath": "../../../.env" "schemaEnvPath": "../../../.env"
}, },
"relativePath": "../../../prisma", "relativePath": "../../../prisma",
"clientVersion": "6.16.3", "clientVersion": "6.17.0",
"engineVersion": "bb420e667c1820a8c05a38023385f6cc7ef8e83a", "engineVersion": "c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"datasourceNames": [ "datasourceNames": [
"db" "db"
], ],

View File

@ -151,7 +151,7 @@
}, },
"./*": "./*" "./*": "./*"
}, },
"version": "6.16.3", "version": "6.17.0",
"sideEffects": false, "sideEffects": false,
"imports": { "imports": {
"#wasm-engine-loader": { "#wasm-engine-loader": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3062,6 +3062,7 @@ declare type QueryPlanNode = {
args: { args: {
from: QueryPlanNode; from: QueryPlanNode;
to: QueryPlanNode; to: QueryPlanNode;
fields: string[];
}; };
} | { } | {
type: 'initializeRecord'; type: 'initializeRecord';

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -35,12 +35,12 @@ exports.Prisma = Prisma
exports.$Enums = {} exports.$Enums = {}
/** /**
* Prisma Client JS version: 6.16.3 * Prisma Client JS version: 6.17.0
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a * Query Engine version: c0aafc03b8ef6cdced8654b9a817999e02457d6a
*/ */
Prisma.prismaVersion = { Prisma.prismaVersion = {
client: "6.16.3", client: "6.17.0",
engine: "bb420e667c1820a8c05a38023385f6cc7ef8e83a" engine: "c0aafc03b8ef6cdced8654b9a817999e02457d6a"
} }
Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError; Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError;
@ -412,7 +412,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": {
@ -426,7 +426,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": {
@ -434,8 +434,8 @@ const config = {
"schemaEnvPath": "../../../.env" "schemaEnvPath": "../../../.env"
}, },
"relativePath": "../../../prisma", "relativePath": "../../../prisma",
"clientVersion": "6.16.3", "clientVersion": "6.17.0",
"engineVersion": "bb420e667c1820a8c05a38023385f6cc7ef8e83a", "engineVersion": "c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"datasourceNames": [ "datasourceNames": [
"db" "db"
], ],

View File

@ -8,6 +8,7 @@ import { reloadTeam } from '@/lib/sse-actions'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useTeamStore } from '@/lib/stores' import { useTeamStore } from '@/lib/stores'
import { TEAM_EVENTS, SELF_EVENTS, SSEEventType } from '@/lib/sseEvents' import { TEAM_EVENTS, SELF_EVENTS, SSEEventType } from '@/lib/sseEvents'
import { useGameBannerStore } from '@/lib/useGameBannerStore' // ← NEU
export default function SSEHandler() { export default function SSEHandler() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
@ -15,16 +16,21 @@ export default function SSEHandler() {
const router = useRouter() const router = useRouter()
const { setTeam, team } = useTeamStore() const { setTeam, team } = useTeamStore()
const { connect, disconnect, lastEvent, source } = useSSEStore() const { connect, source, lastEvent } = useSSEStore()
// Banner-Store
const setBanner = useGameBannerStore(s => s.setGameBanner) // ← NEU
const patchBanner = useGameBannerStore(s => s.patchGameBanner) // ← NEU
// Verbindung (pro User/Gast nur 1x)
const prevSteamId = useRef<string | null>(null) const prevSteamId = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
const id = (status === 'authenticated' && steamId) ? steamId : 'guest'; // <- neu const id = (status === 'authenticated' && steamId) ? steamId : 'guest'
if (!source || prevSteamId.current !== id) { if (!source || prevSteamId.current !== id) {
connect(id); connect(id)
prevSteamId.current = id; prevSteamId.current = id
} }
}, [status, steamId, connect, source]); }, [status, steamId, connect, source])
// parallele Reloads pro Team vermeiden // parallele Reloads pro Team vermeiden
const reloadInFlight = useRef<Set<string>>(new Set()) const reloadInFlight = useRef<Set<string>>(new Set())
@ -37,20 +43,19 @@ export default function SSEHandler() {
if (ts && ts <= lastHandledTs.current) return if (ts && ts <= lastHandledTs.current) return
lastHandledTs.current = ts ?? Date.now() lastHandledTs.current = ts ?? Date.now()
const teamId: string | undefined = payload?.teamId // Robust: Daten können top-level ODER in payload liegen
const data: any = payload ?? lastEvent
const teamId: string | undefined = data?.teamId
const reloadIfNeeded = async (tid?: string) => { const reloadIfNeeded = async (tid?: string) => {
if (!tid) return if (!tid) return
// nur reloaden, wenn es auch das aktuell angezeigte Team ist
if (team?.id && tid !== team.id) return if (team?.id && tid !== team.id) return
if (reloadInFlight.current.has(tid)) return if (reloadInFlight.current.has(tid)) return
reloadInFlight.current.add(tid) reloadInFlight.current.add(tid)
try { try {
const updated = await reloadTeam(tid) const updated = await reloadTeam(tid)
if (updated) { if (updated) startTransition(() => setTeam(updated))
// nicht render-blockierend updaten
startTransition(() => setTeam(updated))
}
} catch (e) { } catch (e) {
console.error('[SSE] reloadTeam failed:', e) console.error('[SSE] reloadTeam failed:', e)
} finally { } finally {
@ -59,15 +64,50 @@ export default function SSEHandler() {
} }
const handleSelfExit = () => { const handleSelfExit = () => {
// nur handeln, wenn wir noch ein Team im Store haben
if (team) { if (team) {
startTransition(() => setTeam(null as any)) startTransition(() => setTeam(null as any))
// replace statt push, um History nicht zu fluten
router.replace('/team') router.replace('/team')
} }
} }
(async () => { ;(async () => {
// ---------- Banner-Events (NEU) ----------
if (type === 'match-ready') {
return
}
if (type === 'server-config-updated') {
const activeMatchId = data?.activeMatchId ?? data?.payload?.activeMatchId
if (activeMatchId) {
setBanner({
visible: true,
variant: 'connected',
serverLabel: 'CS2 Server',
mapKey: data?.activeMapKey ?? data?.payload?.activeMapKey,
mapLabel: data?.activeMapLabel ?? data?.payload?.activeMapLabel,
bgUrl: data?.activeMapBg ?? data?.payload?.activeMapBg,
connectUri: 'steam://rungameid/730',
totalExpected: (data?.activeParticipants ?? data?.payload?.activeParticipants)?.length ?? 10,
connectedCount: 0,
missingCount: 0,
phase: 'warmup',
score: ' : ',
zIndex: 9999,
inline: false,
})
} else {
// Kein aktives Match -> Banner aus
setBanner(null)
}
return
}
if (type === 'match-ended' || type === 'server-reset') {
setBanner(null)
return
}
// ----------------------------------------
// 1) Team-relevante Events → evtl. Reload // 1) Team-relevante Events → evtl. Reload
if (TEAM_EVENTS.has(type as SSEEventType)) { if (TEAM_EVENTS.has(type as SSEEventType)) {
await reloadIfNeeded(teamId) await reloadIfNeeded(teamId)
@ -80,11 +120,10 @@ export default function SSEHandler() {
return return
} }
// 3) alles andere (Notifications etc.) hier ignorieren // 3) alles andere ignorieren (z.B. Notifications → eigene Komponente)
// -> werden in anderen Komponenten ausgewertet
})() })()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEvent, team?.id, setTeam, router]) }, [lastEvent, team?.id, setTeam, router, setBanner, patchBanner])
return null return null
} }

View File

@ -32,8 +32,12 @@ export const SSE_EVENT_TYPES = [
'match-ready', 'match-ready',
'map-vote-reset', 'map-vote-reset',
'ready-updated', 'ready-updated',
'server-config-updated',
'match-ended',
'server-reset',
] as const; ] as const;
export type SSEEventType = typeof SSE_EVENT_TYPES[number]; export type SSEEventType = typeof SSE_EVENT_TYPES[number];
/** Type Guard */ /** Type Guard */
@ -66,6 +70,14 @@ export const INVITE_EVENTS = makeEventSet([
'team-invite-revoked', 'team-invite-revoked',
] as const); ] as const);
export const MAPVOTE_REFRESH = makeEventSet([
'map-vote-updated',
'map-vote-reset',
'map-vote-admin-edit',
'team-leader-changed'
] as const);
export const SELF_EVENTS = makeEventSet([ export const SELF_EVENTS = makeEventSet([
'self-updated', 'self-updated',
'team-created', 'team-created',
@ -86,8 +98,12 @@ export const MATCH_EVENTS = makeEventSet([
'match-ready', 'match-ready',
'map-vote-reset', 'map-vote-reset',
'ready-updated', 'ready-updated',
'server-config-updated',
'match-ended',
'server-reset',
] as const); ] as const);
// Events, die das NotificationCenter betreffen // Events, die das NotificationCenter betreffen
export const NOTIFICATION_EVENTS = makeEventSet([ export const NOTIFICATION_EVENTS = makeEventSet([
'notification', 'notification',
@ -100,6 +116,14 @@ export const NOTIFICATION_EVENTS = makeEventSet([
'expired-sharecode', 'expired-sharecode',
] as const); ] as const);
export const BANNER_EVENTS = makeEventSet([
'match-ready',
'server-config-updated',
'match-ended',
'server-reset',
] as const);
/** Nur noch: akzeptiere kanonische Typen, sonst null */ /** Nur noch: akzeptiere kanonische Typen, sonst null */
export function normalizeEventType(incoming: string): SSEEventType | null { export function normalizeEventType(incoming: string): SSEEventType | null {
return isSseEventType(incoming) ? incoming : null; return isSseEventType(incoming) ? incoming : null;

View File

@ -1,18 +1,48 @@
// /src/lib/useGameBannerStore.ts
'use client' 'use client'
import { create } from 'zustand' import { create } from 'zustand'
type BannerState = { export type GameBannerData = {
visible: boolean visible: boolean
map: { key: string|null, label: string|null, bg: string|null } variant: 'connected' | 'disconnected'
participants: string[] serverLabel?: string
show: (p: { key: string|null, label: string|null, bg: string|null, participants: string[] }) => void mapKey?: string
hide: () => void mapLabel?: string
bgUrl?: string
iconUrl?: string
connectUri?: string
totalExpected?: number
connectedCount?: number
missingCount?: number
phase?: string
score?: string
zIndex?: number
inline?: boolean
} }
export const useGameBannerStore = create<BannerState>((set) => ({ type GameBannerState = {
visible: false, gameBanner: GameBannerData | null
map: { key: null, label: null, bg: null }, gameBannerPx: number
participants: [], setGameBanner: (data: GameBannerData | null) => void
show: ({ key, label, bg, participants }) => set({ visible: true, map: { key, label, bg }, participants }), patchGameBanner: (patch: Partial<GameBannerData>) => void
hide: () => set({ visible: false, participants: [], map: { key: null, label: null, bg: null } }), setGameBannerPx: (px: number) => void
reset: () => void
}
export const useGameBannerStore = create<GameBannerState>((set, get) => ({
gameBanner: null,
gameBannerPx: 0,
setGameBanner: (data) => set({ gameBanner: data }),
patchGameBanner: (patch) => {
const cur = get().gameBanner
if (!cur) return
set({ gameBanner: { ...cur, ...patch } })
},
setGameBannerPx: (px) =>
set({ gameBannerPx: Math.max(0, Math.floor(px)) }),
reset: () => set({ gameBanner: null, gameBannerPx: 0 }),
})) }))

View File

@ -1,12 +0,0 @@
'use client'
import { create } from 'zustand'
type UiChromeState = {
gameBannerPx: number // aktuelle Bannerhöhe in Pixel (0 wenn unsichtbar)
setGameBannerPx: (px: number) => void
}
export const useUiChromeStore = create<UiChromeState>((set) => ({
gameBannerPx: 0,
setGameBannerPx: (px) => set({ gameBannerPx: Math.max(0, Math.floor(px)) }),
}))