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",
"@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.16.3",
"@prisma/client": "^6.17.0",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2",
@ -60,7 +60,7 @@
"@types/ws": "^8.18.1",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"prisma": "^6.16.3",
"prisma": "^6.17.0",
"tailwindcss": "^4.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.19.4",
@ -85,6 +85,7 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -122,7 +123,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -1577,6 +1577,7 @@
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@ -1597,9 +1598,9 @@
"license": "Licensed under MIT and Preline UI Fair Use License"
},
"node_modules/@prisma/client": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.3.tgz",
"integrity": "sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==",
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.0.tgz",
"integrity": "sha512-b42mTLOdLEZ6e/igu8CLdccAUX9AwHknQQ1+pHOftnzDP2QoyZyFvcANqSLs5ockimFKJnV7Ljf+qrhNYf6oAg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -1619,9 +1620,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz",
"integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==",
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz",
"integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -1632,53 +1633,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz",
"integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==",
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz",
"integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz",
"integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==",
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz",
"integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.3",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"@prisma/fetch-engine": "6.16.3",
"@prisma/get-platform": "6.16.3"
"@prisma/debug": "6.17.0",
"@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"@prisma/fetch-engine": "6.17.0",
"@prisma/get-platform": "6.17.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz",
"integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==",
"version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz",
"integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz",
"integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==",
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz",
"integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.3",
"@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"@prisma/get-platform": "6.16.3"
"@prisma/debug": "6.17.0",
"@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"@prisma/get-platform": "6.17.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz",
"integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==",
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz",
"integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.16.3"
"@prisma/debug": "6.17.0"
}
},
"node_modules/@rtsao/scc": {
@ -2067,7 +2068,6 @@
"integrity": "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.19.2"
}
@ -2085,7 +2085,6 @@
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -2183,7 +2182,6 @@
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.30.1",
"@typescript-eslint/types": "8.30.1",
@ -2617,7 +2615,6 @@
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3286,6 +3283,7 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@ -3426,7 +3424,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@ -3883,7 +3880,6 @@
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -4058,7 +4054,6 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@ -5407,6 +5402,7 @@
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@ -5835,6 +5831,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"peer": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -6022,7 +6019,6 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.3.0.tgz",
"integrity": "sha512-k0MgP6BsK8cZ73wRjMazl2y2UcXj49ZXLDEgx6BikWuby/CN+nh81qFFI16edgd7xYpe/jj2OZEIwCoqnzz0bQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.3.0",
"@swc/counter": "0.1.3",
@ -6315,7 +6311,8 @@
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/object-assign": {
"version": "4.1.1",
@ -6332,6 +6329,7 @@
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 6"
}
@ -6460,6 +6458,7 @@
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
@ -6746,6 +6745,7 @@
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pretty-format": "^3.8.0"
},
@ -6776,19 +6776,19 @@
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/prisma": {
"version": "6.16.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz",
"integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==",
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz",
"integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.16.3",
"@prisma/engines": "6.16.3"
"@prisma/config": "6.17.0",
"@prisma/engines": "6.17.0"
},
"bin": {
"prisma": "build/index.js"
@ -6902,7 +6902,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6912,7 +6911,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -6982,7 +6980,8 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@ -7639,8 +7638,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.1",
@ -7697,7 +7695,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7923,7 +7920,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8179,7 +8175,8 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
"license": "ISC",
"peer": true
},
"node_modules/yn": {
"version": "3.1.1",

View File

@ -21,7 +21,7 @@
"@fortawesome/fontawesome-free": "^7.0.0",
"@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.16.3",
"@prisma/client": "^6.17.0",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2",
@ -66,7 +66,7 @@
"@types/ws": "^8.18.1",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"prisma": "^6.16.3",
"prisma": "^6.17.0",
"tailwindcss": "^4.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.19.4",

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@ import type { MapVoteState } from '../../../types/mapvote'
import { MAP_OPTIONS } from '@/lib/mapOptions'
import { Tabs } from './Tabs'
import Chart from './Chart'
import { MAPVOTE_REFRESH } from '@/lib/sseEvents'
/* =================== Utilities & constants =================== */
@ -189,32 +190,6 @@ export default function MapVotePanel({ match }: Props) {
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 -------- */
const adminEditingBy = state?.adminEdit?.by ?? null
const adminEditingEnabled = !!state?.adminEdit?.enabled
@ -227,8 +202,10 @@ export default function MapVotePanel({ match }: Props) {
const me = session?.user
const isAdmin = !!me?.isAdmin
const mySteamId = me?.steamId
const isLeaderA = !!mySteamId && match.teamA?.leader?.steamId === mySteamId
const isLeaderB = !!mySteamId && match.teamB?.leader?.steamId === mySteamId
const leaderAId = state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId ?? null
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
@ -457,6 +434,62 @@ export default function MapVotePanel({ match }: Props) {
[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 amInTeamB = !!mySteamId && playersB.some(p => p.user?.steamId === mySteamId)
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
)
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
useEffect(() => {
if (tab !== 'winrate') return;

View File

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

View File

@ -79,6 +79,7 @@ export default function MiniCard({
const cardClasses = `
relative flex flex-col items-center p-4 border rounded-lg transition
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}
${hoverEffect ? 'hover:cursor-grab hover:scale-105' : ''}
${isSelectable ? 'cursor-pointer' : ''}

View File

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

View File

@ -156,20 +156,42 @@ export default function TelemetrySocket() {
useEffect(() => {
aliveRef.current = true
const connect = () => {
let wsLocal: WebSocket | null = null // <- dieses WS schließen wir im Cleanup
const connectOnce = () => {
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)
wsLocal = ws
wsRef.current = ws
ws.onopen = () => {
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 = () => {
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] error')
}
ws.onclose = () => {
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) => {
@ -241,13 +263,13 @@ export default function TelemetrySocket() {
}
}
connect()
connectOnce()
return () => {
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 {}
}
}, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, hideOverlay, mySteamId])
}, [url])
// 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 clsx from 'clsx'
import { useSSEStore } from '@/lib/useSSEStore'
import type React from 'react'
type Presence = 'online' | 'away' | 'offline'
type Props = {
type Props = React.HTMLAttributes<HTMLDivElement> & {
steamId?: string | null
src: string
alt?: string
@ -17,6 +18,8 @@ type Props = {
isLeader?: boolean
alignRight?: boolean
showStatus?: boolean
/** Zusätzliche Klassen für den runden Avatar-/Ring-Container */
avatarClassName?: string
}
export default function UserAvatarWithStatus({
@ -28,21 +31,20 @@ export default function UserAvatarWithStatus({
isLeader = false,
alignRight = false,
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) {
const { lastEvent } = useSSEStore()
const [status, setStatus] = useState<Presence>(initialStatus)
// Dynamik für Krone (Skalierung & Offset relativ zu size)
const { crownSize, crownOffset, crownIconSize } = useMemo(() => {
// Button-Durchmesser max. 20px
const cs = Math.min(20, Math.round(Math.max(size * 0.5, 14)))
const off = Math.round(size * 0.10) // Offset bleibt dynamisch
// Icon etwas kleiner (z.B. 70% vom Button)
const off = Math.round(size * 0.10)
const icon = Math.round(cs * 0.70)
return { crownSize: cs, crownOffset: off, crownIconSize: icon }
}, [size])
// initialer Status
useEffect(() => {
if (!showStatus || !steamId) return
let alive = true
@ -57,15 +59,12 @@ export default function UserAvatarWithStatus({
return () => { alive = false }
}, [steamId, showStatus])
// SSE-Updates
useEffect(() => {
if (!showStatus || !steamId) return
if (!lastEvent || lastEvent.type !== 'user-status-updated') return
const raw = lastEvent.payload as any
const sid = raw?.steamId ?? raw?.payload?.steamId
const st = (raw?.status ?? raw?.payload?.status) as Presence | undefined
if (sid === steamId && st) setStatus(st)
}, [lastEvent, steamId, showStatus])
@ -78,15 +77,17 @@ export default function UserAvatarWithStatus({
return (
<div
className="relative items-center justify-center"
className={clsx('relative inline-flex items-center justify-center', className)}
style={{ width: size, height: size }}
{...rest}
>
<div
className={clsx(
'flex items-center justify-center rounded-full transition',
showStatus
? ['ring-1 ring-offset-1 ring-offset-white dark:ring-offset-neutral-900', statusRing]
: null
: null,
avatarClassName
)}
style={{ width: size, height: size }}
>
@ -99,7 +100,6 @@ export default function UserAvatarWithStatus({
/>
</div>
{/* Team-Leader-Krone skaliert & positioniert relativ zur Avatargröße */}
{isLeader && (
<span
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">
<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>
</span>
)}
</div>

View File

@ -19,6 +19,7 @@ import AudioPrimer from './components/AudioPrimer';
import ReadyOverlayHost from './components/ReadyOverlayHost';
import TelemetrySocket from './components/TelemetrySocket';
import GameBannerSpacer from './components/GameBannerSpacer';
import GameBannerHost from './components/GameBannerHost';
const geistSans = Geist({variable: '--font-geist-sans', 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">
<div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div>
</main>
<GameBannerHost />
<GameBannerSpacer className="hidden sm:block" />
</div>
</div>

View File

@ -6,7 +6,7 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/lib/sse-server-client'
import { randomInt, createHash, randomBytes } from 'crypto'
import { randomInt, createHash } from 'crypto'
import { MAP_OPTIONS } from '@/lib/mapOptions'
export const runtime = 'nodejs'
@ -469,7 +469,7 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
if (!sLike.locked) return
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)
if (chosen.length < bestOf) return
if (chosen.length < bestOf) return false
// ⬇️ JSON bauen (enthält cs2MatchId/rndId)
const json = buildMatchJson(mLike, sLike)
@ -511,8 +511,6 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
// … dann das neue laden
await sendServerCommand(`matchzy_loadmatch ${filename}`)
// Spieler persistieren + cs2MatchId speichern wie gehabt
await persistMatchPlayers(match)
if (typeof json.matchid === 'number') {
await prisma.match.update({
where: { id: match.id },
@ -524,8 +522,11 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
data: { exportedAt: new Date() },
})
}
return true
} catch (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),
},
})
await sendServerSSEMessage({
type: 'server-config-updated',
payload: {
activeMatchId: match.id,
activeMapKey: key,
activeMapLabel: label,
activeMapBg: bg,
activeParticipants: participants,
},
})
} catch (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 */
async function afterVoteLocked(match: any, vote: any, mapVisuals: Record<string, any>) {
// 1) Spieler für dieses Match festschreiben
// 1) Spieler festschreiben
await persistMatchPlayers(match)
// 2) Live-State für Banner speichern
await writeLiveStateToServerConfig(match, vote, mapVisuals)
// 3) Serverexport + Matchzy-Load
await exportMatchToSftpDirect(match, vote)
// 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)
}
}
/* ---------- kleine Helfer für match-ready Payload ---------- */
function deriveChosenSteps(vote: any) {
@ -697,9 +714,6 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
});
await afterVoteLocked(match, updated, mapVisuals)
// Export serverseitig
await exportMatchToSftpDirect(match, updated)
}
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)
// Export serverseitig
await exportMatchToSftpDirect(match, updated)
}
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 exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })

View File

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

View File

@ -35,12 +35,12 @@ exports.Prisma = Prisma
exports.$Enums = {}
/**
* Prisma Client JS version: 6.16.3
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a
* Prisma Client JS version: 6.17.0
* Query Engine version: c0aafc03b8ef6cdced8654b9a817999e02457d6a
*/
Prisma.prismaVersion = {
client: "6.16.3",
engine: "bb420e667c1820a8c05a38023385f6cc7ef8e83a"
client: "6.17.0",
engine: "c0aafc03b8ef6cdced8654b9a817999e02457d6a"
}
Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError;
@ -412,7 +412,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -426,7 +426,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
@ -434,8 +434,8 @@ const config = {
"schemaEnvPath": "../../../.env"
},
"relativePath": "../../../prisma",
"clientVersion": "6.16.3",
"engineVersion": "bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"clientVersion": "6.17.0",
"engineVersion": "c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"datasourceNames": [
"db"
],

View File

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

View File

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

View File

@ -35,12 +35,12 @@ exports.Prisma = Prisma
exports.$Enums = {}
/**
* Prisma Client JS version: 6.16.3
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a
* Prisma Client JS version: 6.17.0
* Query Engine version: c0aafc03b8ef6cdced8654b9a817999e02457d6a
*/
Prisma.prismaVersion = {
client: "6.16.3",
engine: "bb420e667c1820a8c05a38023385f6cc7ef8e83a"
client: "6.17.0",
engine: "c0aafc03b8ef6cdced8654b9a817999e02457d6a"
}
Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError;
@ -413,7 +413,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -427,7 +427,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
@ -435,8 +435,8 @@ const config = {
"schemaEnvPath": "../../../.env"
},
"relativePath": "../../../prisma",
"clientVersion": "6.16.3",
"engineVersion": "bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"clientVersion": "6.17.0",
"engineVersion": "c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"datasourceNames": [
"db"
],

View File

@ -151,7 +151,7 @@
},
"./*": "./*"
},
"version": "6.16.3",
"version": "6.17.0",
"sideEffects": false,
"imports": {
"#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: {
from: QueryPlanNode;
to: QueryPlanNode;
fields: string[];
};
} | {
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 = {}
/**
* Prisma Client JS version: 6.16.3
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a
* Prisma Client JS version: 6.17.0
* Query Engine version: c0aafc03b8ef6cdced8654b9a817999e02457d6a
*/
Prisma.prismaVersion = {
client: "6.16.3",
engine: "bb420e667c1820a8c05a38023385f6cc7ef8e83a"
client: "6.17.0",
engine: "c0aafc03b8ef6cdced8654b9a817999e02457d6a"
}
Prisma.PrismaClientKnownRequestError = PrismaClientKnownRequestError;
@ -412,7 +412,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -426,7 +426,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {
@ -434,8 +434,8 @@ const config = {
"schemaEnvPath": "../../../.env"
},
"relativePath": "../../../prisma",
"clientVersion": "6.16.3",
"engineVersion": "bb420e667c1820a8c05a38023385f6cc7ef8e83a",
"clientVersion": "6.17.0",
"engineVersion": "c0aafc03b8ef6cdced8654b9a817999e02457d6a",
"datasourceNames": [
"db"
],

View File

@ -8,6 +8,7 @@ import { reloadTeam } from '@/lib/sse-actions'
import { useRouter } from 'next/navigation'
import { useTeamStore } from '@/lib/stores'
import { TEAM_EVENTS, SELF_EVENTS, SSEEventType } from '@/lib/sseEvents'
import { useGameBannerStore } from '@/lib/useGameBannerStore' // ← NEU
export default function SSEHandler() {
const { data: session, status } = useSession()
@ -15,16 +16,21 @@ export default function SSEHandler() {
const router = useRouter()
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)
useEffect(() => {
const id = (status === 'authenticated' && steamId) ? steamId : 'guest'; // <- neu
if (!source || prevSteamId.current !== id) {
connect(id);
prevSteamId.current = id;
}
}, [status, steamId, connect, source]);
const id = (status === 'authenticated' && steamId) ? steamId : 'guest'
if (!source || prevSteamId.current !== id) {
connect(id)
prevSteamId.current = id
}
}, [status, steamId, connect, source])
// parallele Reloads pro Team vermeiden
const reloadInFlight = useRef<Set<string>>(new Set())
@ -37,20 +43,19 @@ export default function SSEHandler() {
if (ts && ts <= lastHandledTs.current) return
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) => {
if (!tid) return
// nur reloaden, wenn es auch das aktuell angezeigte Team ist
if (team?.id && tid !== team.id) return
if (reloadInFlight.current.has(tid)) return
reloadInFlight.current.add(tid)
try {
const updated = await reloadTeam(tid)
if (updated) {
// nicht render-blockierend updaten
startTransition(() => setTeam(updated))
}
if (updated) startTransition(() => setTeam(updated))
} catch (e) {
console.error('[SSE] reloadTeam failed:', e)
} finally {
@ -59,15 +64,50 @@ export default function SSEHandler() {
}
const handleSelfExit = () => {
// nur handeln, wenn wir noch ein Team im Store haben
if (team) {
startTransition(() => setTeam(null as any))
// replace statt push, um History nicht zu fluten
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
if (TEAM_EVENTS.has(type as SSEEventType)) {
await reloadIfNeeded(teamId)
@ -80,11 +120,10 @@ export default function SSEHandler() {
return
}
// 3) alles andere (Notifications etc.) hier ignorieren
// -> werden in anderen Komponenten ausgewertet
// 3) alles andere ignorieren (z.B. Notifications → eigene Komponente)
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEvent, team?.id, setTeam, router])
}, [lastEvent, team?.id, setTeam, router, setBanner, patchBanner])
return null
}

View File

@ -32,8 +32,12 @@ export const SSE_EVENT_TYPES = [
'match-ready',
'map-vote-reset',
'ready-updated',
'server-config-updated',
'match-ended',
'server-reset',
] as const;
export type SSEEventType = typeof SSE_EVENT_TYPES[number];
/** Type Guard */
@ -66,6 +70,14 @@ export const INVITE_EVENTS = makeEventSet([
'team-invite-revoked',
] 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([
'self-updated',
'team-created',
@ -86,8 +98,12 @@ export const MATCH_EVENTS = makeEventSet([
'match-ready',
'map-vote-reset',
'ready-updated',
'server-config-updated',
'match-ended',
'server-reset',
] as const);
// Events, die das NotificationCenter betreffen
export const NOTIFICATION_EVENTS = makeEventSet([
'notification',
@ -100,6 +116,14 @@ export const NOTIFICATION_EVENTS = makeEventSet([
'expired-sharecode',
] 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 */
export function normalizeEventType(incoming: string): SSEEventType | null {
return isSseEventType(incoming) ? incoming : null;

View File

@ -1,18 +1,48 @@
// /src/lib/useGameBannerStore.ts
'use client'
import { create } from 'zustand'
type BannerState = {
export type GameBannerData = {
visible: boolean
map: { key: string|null, label: string|null, bg: string|null }
participants: string[]
show: (p: { key: string|null, label: string|null, bg: string|null, participants: string[] }) => void
hide: () => void
variant: 'connected' | 'disconnected'
serverLabel?: string
mapKey?: string
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) => ({
visible: false,
map: { key: null, label: null, bg: null },
participants: [],
show: ({ key, label, bg, participants }) => set({ visible: true, map: { key, label, bg }, participants }),
hide: () => set({ visible: false, participants: [], map: { key: null, label: null, bg: null } }),
type GameBannerState = {
gameBanner: GameBannerData | null
gameBannerPx: number
setGameBanner: (data: GameBannerData | null) => void
patchGameBanner: (patch: Partial<GameBannerData>) => void
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)) }),
}))