This commit is contained in:
Linrador 2025-08-13 15:25:04 +02:00
parent 5d0150d903
commit bdb5cbb4e1
18 changed files with 939 additions and 477 deletions

308
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.13.0",
"@prisma/client": "^6.14.0",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"csgo-sharecode": "^3.1.2",
@ -57,7 +57,7 @@
"@types/ws": "^8.18.1",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"prisma": "^6.13.0",
"prisma": "^6.14.0",
"tailwindcss": "^4.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.19.4",
@ -77,31 +77,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
@ -1620,9 +1595,9 @@
"license": "Licensed under MIT and Preline UI Fair Use License"
},
"node_modules/@prisma/client": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.13.0.tgz",
"integrity": "sha512-8m2+I3dQovkV8CkDMluiwEV1TxV9EXdT6xaCz39O6jYw7mkf5gwfmi+cL4LJsEPwz5tG7sreBwkRpEMJedGYUQ==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.14.0.tgz",
"integrity": "sha512-8E/Nk3eL5g7RQIg/LUj1ICyDmhD053STjxrPxUtCRybs2s/2sOEcx9NpITuAOPn07HEpWBfhAVe1T/HYWXUPOw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -1642,66 +1617,66 @@
}
},
"node_modules/@prisma/config": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.13.0.tgz",
"integrity": "sha512-OYMM+pcrvj/NqNWCGESSxVG3O7kX6oWuGyvufTUNnDw740KIQvNyA4v0eILgkpuwsKIDU36beZCkUtIt0naTog==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz",
"integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
"deepmerge-ts": "7.1.5",
"effect": "3.16.12",
"read-package-up": "11.0.0"
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.13.0.tgz",
"integrity": "sha512-um+9pfKJW0ihmM83id9FXGi5qEbVJ0Vxi1Gm0xpYsjwUBnw6s2LdPBbrsG9QXRX46K4CLWCTNvskXBup4i9hlw==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz",
"integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.13.0.tgz",
"integrity": "sha512-D+1B79LFvtWA0KTt8ALekQ6A/glB9w10ETknH5Y9g1k2NYYQOQy93ffiuqLn3Pl6IPJG3EsK/YMROKEaq8KBrA==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz",
"integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.13.0",
"@prisma/engines-version": "6.13.0-35.361e86d0ea4987e9f53a565309b3eed797a6bcbd",
"@prisma/fetch-engine": "6.13.0",
"@prisma/get-platform": "6.13.0"
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/fetch-engine": "6.14.0",
"@prisma/get-platform": "6.14.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.13.0-35.361e86d0ea4987e9f53a565309b3eed797a6bcbd",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.13.0-35.361e86d0ea4987e9f53a565309b3eed797a6bcbd.tgz",
"integrity": "sha512-MpPyKSzBX7P/ZY9odp9TSegnS/yH3CSbchQE9f0yBg3l2QyN59I6vGXcoYcqKC9VTniS1s18AMmhyr1OWavjHg==",
"version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz",
"integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.13.0.tgz",
"integrity": "sha512-grmmq+4FeFKmaaytA8Ozc2+Tf3BC8xn/DVJos6LL022mfRlMZYjT3hZM0/xG7+5fO95zFG9CkDUs0m1S2rXs5Q==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz",
"integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.13.0",
"@prisma/engines-version": "6.13.0-35.361e86d0ea4987e9f53a565309b3eed797a6bcbd",
"@prisma/get-platform": "6.13.0"
"@prisma/debug": "6.14.0",
"@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49",
"@prisma/get-platform": "6.14.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.13.0.tgz",
"integrity": "sha512-Nii2pX50fY4QKKxQwm7/vvqT6Ku8yYJLZAFX4e2vzHwRdMqjugcOG5hOSLjxqoXb0cvOspV70TOhMzrw8kqAnw==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz",
"integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.13.0"
"@prisma/debug": "6.14.0"
}
},
"node_modules/@rtsao/scc": {
@ -2101,13 +2076,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/normalize-package-data": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
@ -3556,6 +3524,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@ -4383,19 +4361,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-up-simple": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz",
"integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flag-icons": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.3.2.tgz",
@ -4823,26 +4788,6 @@
"node": ">= 0.4"
}
},
"node_modules/hosted-git-info": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
"integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^10.0.1"
},
"engines": {
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/hosted-git-info/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -4880,19 +4825,6 @@
"node": ">=0.8.19"
}
},
"node_modules/index-to-position": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.1.0.tgz",
"integrity": "sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@ -5383,7 +5315,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@ -6222,21 +6154,6 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/normalize-package-data": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz",
"integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==",
"devOptional": true,
"license": "BSD-2-Clause",
"dependencies": {
"hosted-git-info": "^7.0.0",
"semver": "^7.3.5",
"validate-npm-package-license": "^3.0.4"
},
"engines": {
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/nouislider": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/nouislider/-/nouislider-15.8.1.tgz",
@ -6557,24 +6474,6 @@
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -6736,15 +6635,15 @@
"peer": true
},
"node_modules/prisma": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.13.0.tgz",
"integrity": "sha512-dfzORf0AbcEyyzxuv2lEwG8g+WRGF/qDQTpHf/6JoHsyF5MyzCEZwClVaEmw3WXcobgadosOboKUgQU0kFs9kw==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz",
"integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.13.0",
"@prisma/engines": "6.13.0"
"@prisma/config": "6.14.0",
"@prisma/engines": "6.14.0"
},
"bin": {
"prisma": "build/index.js"
@ -6891,44 +6790,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/read-package-up": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
"integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"find-up-simple": "^1.0.0",
"read-pkg": "^9.0.0",
"type-fest": "^4.6.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/read-pkg": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz",
"integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/normalize-package-data": "^2.4.3",
"normalize-package-data": "^6.0.0",
"parse-json": "^8.0.0",
"type-fest": "^4.6.0",
"unicorn-magic": "^0.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -7382,42 +7243,6 @@
"node": ">=0.10.0"
}
},
"node_modules/spdx-correct": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"spdx-expression-parse": "^3.0.0",
"spdx-license-ids": "^3.0.0"
}
},
"node_modules/spdx-exceptions": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
"integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
"devOptional": true,
"license": "CC-BY-3.0"
},
"node_modules/spdx-expression-parse": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"spdx-exceptions": "^2.1.0",
"spdx-license-ids": "^3.0.0"
}
},
"node_modules/spdx-license-ids": {
"version": "3.0.21",
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz",
"integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==",
"devOptional": true,
"license": "CC0-1.0"
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@ -7824,19 +7649,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"devOptional": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@ -7955,19 +7767,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
"integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unrs-resolver": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.5.0.tgz",
@ -8042,17 +7841,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"spdx-correct": "^3.0.0",
"spdx-expression-parse": "^3.0.0"
}
},
"node_modules/vanilla-calendar-pro": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/vanilla-calendar-pro/-/vanilla-calendar-pro-3.0.4.tgz",

View File

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

View File

@ -77,7 +77,7 @@ export default function TeamAdminClient({ teamId }: Props) {
if (loading || !team) return <LoadingSpinner />
return (
<div className="max-w-5xl mx-auto">
<div className="mx-auto">
<TeamMemberView
key={
team

View File

@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
@ -25,6 +26,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
return NextResponse.json({ message: 'Match nicht gefunden.' }, { status: 404 })
}
// Alles in einer Transaktion löschen
await prisma.$transaction(async (tx) => {
await tx.mapVetoStep.deleteMany({ where: { veto: { matchId } } })
await tx.mapVeto.deleteMany({ where: { matchId } })
@ -33,13 +35,20 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
await tx.rankHistory.deleteMany({ where: { matchId } })
await tx.demoFile.deleteMany({ where: { matchId } })
await tx.serverRequest.deleteMany({ where: { matchId } })
await tx.schedule.deleteMany({
where: { linkedMatchId: matchId },
})
await tx.schedule.deleteMany({ where: { linkedMatchId: matchId } })
await tx.match.delete({ where: { id: matchId } })
})
return NextResponse.json({ ok: true })
// 🔔 Realtime-Broadcasts (flat payload)
try {
await sendServerSSEMessage({ type: 'match-deleted', matchId })
await sendServerSSEMessage({ type: 'matches-updated' })
} catch (e) {
// Broadcast-Fehler sollen das Löschen nicht rückgängig machen
console.error('[DELETE MATCH] SSE broadcast failed', e)
}
return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } })
} catch (e) {
console.error('[DELETE MATCH] error', e)
return NextResponse.json({ message: 'Löschen fehlgeschlagen.' }, { status: 500 })

View File

@ -2,8 +2,8 @@
import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma' // gemeinsame Prisma-Instanz nutzen
import { MapVetoAction } from '@/generated/prisma' // dein Prisma-Types-Output
import { prisma } from '@/app/lib/prisma'
import { MapVetoAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
/* -------------------- Konstanten -------------------- */
@ -74,12 +74,52 @@ function shapeState(veto: any) {
}
}
// Leader -> Player-Shape für das Frontend mappen
function shapeLeader(leader: any | null) {
if (!leader) return null
return {
steamId : leader.steamId,
name : leader.name ?? '',
avatar : leader.avatar ?? '',
location : leader.location ?? undefined,
premierRank: leader.premierRank ?? undefined,
isAdmin : leader.isAdmin ?? undefined,
}
}
async function ensureVeto(matchId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA : true,
teamB : true,
teamA : {
include: {
// WICHTIG: Leader-Relation als Objekt laden
leader: {
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
isAdmin: true,
}
}
}
},
teamB : {
include: {
leader: {
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
isAdmin: true,
}
}
}
},
mapVeto: { include: { steps: true } },
},
})
@ -128,13 +168,29 @@ export async function GET(_req: NextRequest, { params }: { params: { id: string
const matchId = params.id
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
const { veto } = await ensureVeto(matchId)
if (!veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const { match, veto } = await ensureVeto(matchId)
if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
return NextResponse.json(
shapeState(veto),
{ headers: { 'Cache-Control': 'no-store' } },
)
// Veto-State + Teams (mit Leader-Objekt) zurückgeben
const payload = {
...shapeState(veto),
teams: {
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
},
},
}
return NextResponse.json(payload, { headers: { 'Cache-Control': 'no-store' } })
} catch (e) {
console.error('[map-vote][GET] error', e)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
@ -173,9 +229,32 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
if (!current) {
// Kein Schritt mehr -> Veto abschließen
await prisma.mapVeto.update({ where: { id: veto.id }, data: { locked: true } })
const updated = await prisma.mapVeto.findUnique({ where: { id: veto.id }, include: { steps: true } })
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
return NextResponse.json(shapeState(updated))
const updated = await prisma.mapVeto.findUnique({
where: { id: veto.id },
include: { steps: true },
})
// 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
return NextResponse.json({
...shapeState(updated),
teams: {
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
},
},
})
}
const available = computeAvailableMaps(veto.mapPool, stepsSorted)
@ -196,14 +275,37 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
data : { currentIdx: veto.currentIdx + 1, locked: true },
})
})
const updated = await prisma.mapVeto.findUnique({ where: { id: veto.id }, include: { steps: true } })
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
return NextResponse.json(shapeState(updated))
const updated = await prisma.mapVeto.findUnique({
where: { id: veto.id },
include: { steps: true },
})
// 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
return NextResponse.json({
...shapeState(updated),
teams: {
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
},
},
})
}
// Rechte prüfen (Admin oder Leader des Teams am Zug)
const isLeaderA = !!match.teamA?.leaderId && match.teamA.leaderId === me.steamId
const isLeaderB = !!match.teamB?.leaderId && match.teamB.leaderId === me.steamId
// Rechte prüfen (Admin oder Leader des Teams am Zug) weiterhin via leaderId
const isLeaderA = !!(match as any).teamA?.leaderId && (match as any).teamA.leaderId === me.steamId
const isLeaderB = !!(match as any).teamB?.leaderId && (match as any).teamB.leaderId === me.steamId
const allowed = me.isAdmin || (current.teamId && (
(current.teamId === match.teamA?.id && isLeaderA) ||
(current.teamId === match.teamB?.id && isLeaderB)
@ -264,8 +366,26 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
include: { steps: true },
})
await sendServerSSEMessage({ type: 'map-vote-updated', payload: { matchId } })
return NextResponse.json(shapeState(updated))
// 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
return NextResponse.json({
...shapeState(updated),
teams: {
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
},
},
})
} catch (e) {
console.error('[map-vote][POST] error', e)
return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 })

View File

@ -102,13 +102,13 @@ export async function POST (req: NextRequest) {
const newMatch = await tx.match.create({
data: {
teamAId, teamBId,
title: safeTitle,
description: safeDesc,
map: safeMap,
demoDate: plannedAt, // du nutzt demoDate als geplante Zeit
bestOf: bestOfInt,
teamAUsers: { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
teamBUsers: { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) },
title : safeTitle,
description : safeDesc,
map : safeMap,
demoDate : plannedAt, // geplanter Startzeitpunkt
bestOf : bestOfInt,
teamAUsers : { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
teamBUsers : { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) },
},
})
@ -120,21 +120,21 @@ export async function POST (req: NextRequest) {
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
}
// ⬇️ MapVeto sofort anlegen
// MapVeto sofort anlegen
const opensAt = new Date((new Date(newMatch.matchDate ?? newMatch.demoDate ?? plannedAt)).getTime() - 60*60*1000)
const stepsDef = buildSteps(bestOfInt, teamAId, teamBId)
await tx.mapVeto.create({
data: {
matchId: newMatch.id,
bestOf : bestOfInt,
mapPool: ACTIVE_DUTY,
matchId : newMatch.id,
bestOf : bestOfInt,
mapPool : ACTIVE_DUTY,
currentIdx: 0,
locked: false,
locked : false,
opensAt,
steps: {
steps : {
create: stepsDef.map(s => ({
order: s.order,
order : s.order,
action: s.action, // prisma.MapVetoAction.*
teamId: s.teamId ?? undefined,
})),
@ -145,7 +145,7 @@ export async function POST (req: NextRequest) {
return newMatch
})
// ── Notifications + SSE
// ── Notifications + SSE (per-User)
const targets = Array.from(new Set<string>([
teamA.leaderId,
...(teamA.activePlayers ?? []),
@ -192,11 +192,24 @@ export async function POST (req: NextRequest) {
)
}
await sendServerSSEMessage({
type: 'matches-updated',
targetUserIds: allTargets,
message: 'Neue Matchplanung verfügbar.',
})
// 🔔 Globale Broadcasts (flat payload) für Live-Updates aller Clients
try {
await sendServerSSEMessage({
type : 'match-created',
matchId : created.id,
teamAId,
teamBId,
title : safeTitle,
startsAt: (created.matchDate ?? created.demoDate ?? plannedAt).toISOString(),
bestOf : bestOfInt,
})
await sendServerSSEMessage({ type: 'matches-updated' })
// optional zusätzlich (falls Team-Ansichten reagieren sollen):
// await sendServerSSEMessage({ type: 'team-updated', teamId: teamAId })
// await sendServerSSEMessage({ type: 'team-updated', teamId: teamBId })
} catch (e) {
console.error('[CREATE MATCH] SSE broadcast failed', e)
}
return NextResponse.json(
{ success: true, match: created },

View File

@ -11,6 +11,7 @@ import Switch from '@/app/components/Switch'
import Button from './Button'
import Modal from './Modal'
import { Match } from '../types/match'
import { useSSEStore } from '@/app/lib/useSSEStore'
type Props = { matchType?: string }
@ -43,6 +44,7 @@ function getNextHourDefaults() {
export default function CommunityMatchList({ matchType }: Props) {
const { data: session } = useSession()
const router = useRouter()
const { lastEvent } = useSSEStore()
const [matches, setMatches] = useState<Match[]>([])
const [onlyOwn, setOnlyOwn] = useState(false)
@ -99,6 +101,28 @@ export default function CommunityMatchList({ matchType }: Props) {
}, [matchType])
useEffect(() => { loadMatches() }, [loadMatches])
useEffect(() => {
if (!lastEvent) return
// auf diese Typen reagieren
const TRIGGER_TYPES = new Set([
'match-created',
'matches-updated',
'match-deleted',
'map-vote-updated', // damit das Map-Vote Badge live aktualisiert
])
if (!TRIGGER_TYPES.has(lastEvent.type)) return
// kurzer Cooldown, falls mehrere Events gleichzeitig eintreffen
let cancelled = false
const t = setTimeout(async () => {
if (!cancelled) await loadMatches()
}, 150)
return () => { cancelled = true; clearTimeout(t) }
}, [lastEvent, loadMatches])
// Teams laden, wenn Modal aufgeht
useEffect(() => {

View File

@ -0,0 +1,191 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore'
import type { Match } from '../types/match'
import type { MapVetoState } from '../types/mapvote'
type Props = {
match: Match
}
export default function MapVoteBanner({ match }: Props) {
const router = useRouter()
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
const [state, setState] = useState<MapVetoState | null>(null)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
try {
setError(null)
const r = await fetch(`/api/matches/${match.id}/map-vote`, { cache: 'no-store' })
if (!r.ok) {
const j = await r.json().catch(() => ({}))
throw new Error(j?.message || 'Laden fehlgeschlagen')
}
const json = await r.json()
if (!json || !Array.isArray(json.steps)) {
throw new Error('Ungültige Serverantwort (steps fehlt)')
}
setState(json)
} catch (e: any) {
setState(null)
setError(e?.message ?? 'Unbekannter Fehler')
}
}, [match.id])
useEffect(() => { load() }, [load])
// Live-Refresh via SSE
useEffect(() => {
if (!lastEvent) return
if (lastEvent.type !== 'map-vote-updated') return
const mId = lastEvent.payload?.matchId
if (mId !== match.id) return
load()
}, [lastEvent, match.id, load])
// Öffnungslogik (Fallback: 1h vor Match-/Demozeit)
const opensAt = useMemo(() => {
if (state?.opensAt) return new Date(state.opensAt).getTime()
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
return base.getTime() - 60 * 60 * 1000
}, [state?.opensAt, match.matchDate, match.demoDate])
const [nowTs, setNowTs] = useState(() => Date.now())
useEffect(() => {
const t = setInterval(() => setNowTs(Date.now()), 1000)
return () => clearInterval(t)
}, [])
const isOpen = nowTs >= opensAt
const msToOpen = Math.max(opensAt - nowTs, 0)
// Wer ist am Zug?
const current = state?.steps?.[state.currentIndex]
const whoIsUp = current?.teamId
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
: null
// Rechte nur für Text
const isLeaderA = !!session?.user?.steamId && match.teamA?.leader === session.user.steamId
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader === session.user.steamId
const isAdmin = !!session?.user?.isAdmin
const iCanAct = Boolean(
isOpen &&
!state?.locked &&
current?.teamId &&
(isAdmin ||
(current.teamId === match.teamA?.id && isLeaderA) ||
(current.teamId === match.teamB?.id && isLeaderB))
)
const gotoFullPage = () => router.push(`/match-details/${match.id}/map-vote`)
const cardClasses =
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
'dark:border-neutral-700 shadow-sm transition cursor-pointer focus:outline-none ' +
(isOpen
? 'ring-1 ring-blue-500/20 hover:ring-blue-500/30 hover:shadow-md'
: 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md')
return (
<div
role="button"
tabIndex={0}
onClick={gotoFullPage}
onKeyDown={(e) => e.key === 'Enter' && gotoFullPage()}
className={cardClasses}
aria-label="Map-Vote öffnen"
>
{isOpen && <div aria-hidden className="absolute inset-0 z-0 pointer-events-none mapVoteGradient" />}
<div className="relative z-[1] px-4 py-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="shrink-0 w-9 h-9 rounded-full grid place-items-center bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
<path d="M15 4.5 9 7.5l-6-3v15l6 3 6-3 6 3v-15l-6-3Zm-6 16.5-4-2V6l4 2v13Zm2-13 4-2v13l-4 2V8Z"/>
</svg>
</div>
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-neutral-100">
Map-Vote
</div>
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
{state?.locked
? ' • Auswahl fixiert'
: isOpen
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
: ' • startet 1h vor Matchbeginn'}
</div>
{error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-0.5">
{error}
</div>
)}
</div>
</div>
<div className="shrink-0">
{state?.locked ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
Veto abgeschlossen
</span>
) : isOpen ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
</span>
) : (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100">
Öffnet in {formatCountdown(msToOpen)}
</span>
)}
</div>
</div>
<style jsx>{`
@keyframes slide-x {
from { background-position-x: 0%; }
to { background-position-x: 200%; }
}
.mapVoteGradient {
background-image: repeating-linear-gradient(
90deg,
rgba(37, 99, 235, 0.20) 0%,
rgba(37, 99, 235, 0.05) 50%,
rgba(37, 99, 235, 0.20) 100%
);
background-size: 200% 100%;
background-repeat: repeat-x;
animation: slide-x 3s linear infinite;
}
:global(.dark) .mapVoteGradient {
background-image: repeating-linear-gradient(
90deg,
rgba(37, 99, 235, 0.30) 0%,
rgba(37, 99, 235, 0.10) 50%,
rgba(37, 99, 235, 0.30) 100%
);
}
@media (prefers-reduced-motion: reduce) {
.mapVoteGradient { animation: none; }
}
`}</style>
</div>
)
}
function formatCountdown(ms: number) {
if (ms <= 0) return '0:00:00'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
const pad = (n:number)=>String(n).padStart(2,'0')
return `${h}:${pad(m)}:${pad(s)}`
}

View File

@ -1,45 +1,58 @@
// /app/components/MapVotePanel.tsx
'use client'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { mapNameMap } from '../lib/mapNameMap'
import Button from './Button'
import { useSSEStore } from '@/app/lib/useSSEStore'
import type { Match } from '../types/match'
import { mapNameMap } from '../lib/mapNameMap'
import MapVoteProfileCard from './MapVoteProfileCard'
import type { Match, MatchPlayer } from '../types/match'
import type { MapVetoState } from '../types/mapvote'
import { Player } from '../types/team'
type Props = { match: Match }
const getTeamLogo = (logo?: string | null) =>
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
const HOLD_MS = 1200 // Dauer zum Gedrückthalten (ms)
const COMPLETE_THRESHOLD = 1.00 // ab diesem Fortschritt gilt "fertig"
export default function MapVotePanel({ match }: Props) {
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
const [state, setState] = useState<MapVetoState | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// --- Zeitpunkt: 1h vor Matchbeginn ---
// --- Zeitpunkt: 1h vor Match-/Demo-Beginn (Fallback) ---
const opensAtTs = useMemo(() => {
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
return base.getTime() - 60 * 60 * 1000
}, [match.matchDate, match.demoDate])
// Sauberer Countdown/„Jetzt offen“-Trigger
// „Jetzt offen“-Trigger
const [nowTs, setNowTs] = useState(() => Date.now())
const isOpen = nowTs >= opensAtTs
const msToOpen = Math.max(opensAtTs - nowTs, 0)
useEffect(() => {
if (isOpen) return
const t = setInterval(() => setNowTs(Date.now()), 1000)
return () => clearInterval(t)
}, [isOpen])
}, [])
const isOpenFromMatch = nowTs >= opensAtTs
// --- Berechtigungen: nur Leader des Teams, Admins immer ---
// --- Rollen ---
const me = session?.user
const isAdmin = !!me?.isAdmin
const isLeaderA = !!me?.steamId && match.teamA?.leader === me.steamId
const isLeaderB = !!me?.steamId && match.teamB?.leader === me.steamId
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 = !!me?.steamId && match.teamA?.leader?.steamId === me.steamId
const isLeaderB = !!me?.steamId && match.teamB?.leader?.steamId === me.steamId
console.log("me.steamId: ", me?.steamId);
console.log("match.teamA?.leader?.steamId: ", match.teamA?.leader?.steamId);
console.log("match.teamB?.leader?.steamId: ", match.teamB?.leader?.steamId);
const canActForTeamId = useCallback((teamId?: string | null) => {
if (!teamId) return false
@ -50,35 +63,30 @@ export default function MapVotePanel({ match }: Props) {
// --- Laden / Reload ---
const load = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const r = await fetch(`/api/matches/${match.id}/map-vote`, { cache: 'no-store' })
if (!r.ok) {
// Server-Fehlertext übernehmen, falls vorhanden
const j = await r.json().catch(() => ({}))
throw new Error(j?.message || 'Laden fehlgeschlagen')
setIsLoading(true)
setError(null)
try {
const r = await fetch(`/api/matches/${match.id}/map-vote`, { cache: 'no-store' })
if (!r.ok) {
const j = await r.json().catch(() => ({}))
throw new Error(j?.message || 'Laden fehlgeschlagen')
}
const json = await r.json()
if (!json || !Array.isArray(json.steps)) {
throw new Error('Ungültige Serverantwort (steps fehlt)')
}
setState(json)
} catch (e: any) {
setState(null)
setError(e?.message ?? 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
const json = await r.json()
// ➜ harte Validierung der erwarteten Struktur
if (!json || !Array.isArray(json.steps)) {
// optional: console.debug('Unerwartete Antwort:', json)
throw new Error('Ungültige Serverantwort (steps fehlt)')
}
setState(json)
} catch (e: any) {
setState(null) // wichtig: kein halbgares Objekt rendern
setError(e?.message ?? 'Unbekannter Fehler')
} finally {
setIsLoading(false)
}
}, [match.id])
}, [match.id])
useEffect(() => { load() }, [load])
// --- SSE: bei Änderungen nachladen ---
const { lastEvent } = useSSEStore()
// --- SSE: live nachladen ---
useEffect(() => {
if (!lastEvent) return
if (lastEvent.type !== 'map-vote-updated') return
@ -87,17 +95,26 @@ export default function MapVotePanel({ match }: Props) {
load()
}, [lastEvent, match.id, load])
// --- Abgeleitete Zustände ---
const currentStep = state?.steps?.[state.currentIndex]
// --- Abgeleitet ---
const opensAt = useMemo(() => state?.opensAt ? new Date(state.opensAt).getTime() : null, [state?.opensAt])
const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch
const msToOpen = Math.max((opensAt ?? opensAtTs) - nowTs, 0)
const currentStep = state?.steps?.[state?.currentIndex ?? 0]
const isMyTurn = Boolean(
isOpen && !state?.locked && currentStep?.teamId && canActForTeamId(currentStep.teamId)
)
const mapPool = state?.mapPool ?? []
const pickedOrBanned = useMemo(
() => new Set((state?.steps ?? []).map(s => s.map).filter(Boolean) as string[]),
[state?.steps]
)
// Map -> (action, teamId) wenn bereits entschieden
const decisionByMap = useMemo(() => {
const map = new Map<string, { action: 'ban'|'pick'|'decider'; teamId: string | null }>()
for (const s of (state?.steps ?? [])) {
if (s.map) map.set(s.map, { action: s.action as any, teamId: s.teamId ?? null })
}
return map
}, [state?.steps])
const fmt = (k: string) => mapNameMap[k]?.name ?? k
@ -115,22 +132,97 @@ export default function MapVotePanel({ match }: Props) {
alert(j.message ?? 'Aktion fehlgeschlagen')
return
}
// Erfolg: Server triggert SSE → load() via SSE
// Erfolg -> SSE triggert load()
} catch {
alert('Netzwerkfehler')
}
}
// --- Press-and-hold Logik (pro Map) ---
const rafRef = useRef<number | null>(null)
const holdStartRef = useRef<number | null>(null)
const holdMapRef = useRef<string | null>(null)
const submittedRef = useRef<boolean>(false) // gegen Doppel-Submit
const [progressByMap, setProgressByMap] = useState<Record<string, number>>({})
const resetHold = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = null
holdStartRef.current = null
holdMapRef.current = null
submittedRef.current = false
}, [])
const finishAndSubmit = useCallback((map: string) => {
if (submittedRef.current) return
submittedRef.current = true
setTimeout(() => handlePickOrBan(map), 10)
}, [handlePickOrBan])
const stepHold = useCallback((ts: number) => {
if (!holdStartRef.current || !holdMapRef.current) return
const elapsed = ts - holdStartRef.current
const p = Math.min(1, elapsed / HOLD_MS)
const map = holdMapRef.current
setProgressByMap(prev => ({ ...prev, [map]: p }))
if (p >= COMPLETE_THRESHOLD) {
const doneMap = map
resetHold()
finishAndSubmit(doneMap)
return
}
rafRef.current = requestAnimationFrame(stepHold)
}, [resetHold, finishAndSubmit])
const onHoldStart = useCallback((map: string, allowed: boolean) => {
if (!allowed) return
resetHold()
holdMapRef.current = map
holdStartRef.current = performance.now()
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
rafRef.current = requestAnimationFrame(stepHold)
}, [stepHold, resetHold])
const cancelOrSubmitIfComplete = useCallback((map: string) => {
const p = progressByMap[map] ?? 0
if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) {
resetHold()
finishAndSubmit(map)
return
}
if (holdMapRef.current === map) {
resetHold()
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
}
}, [progressByMap, resetHold, finishAndSubmit])
// Touch-Unterstützung
const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => {
e.preventDefault()
onHoldStart(map, allowed)
}
const onTouchEnd = (map: string) => (e: React.TouchEvent) => {
e.preventDefault()
cancelOrSubmitIfComplete(map)
}
if (isLoading && !state) return <div className="p-4">Lade Map-Voting</div>
if (error && !state) return <div className="p-4 text-red-600">{error}</div>
const playersA = match.teamA.players as unknown as MatchPlayer[]
const playersB = match.teamB.players as unknown as MatchPlayer[]
return (
<div className="mt-8 border rounded-lg p-4 dark:border-neutral-700">
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Map-Vote</h3>
<div className="text-sm opacity-80">Modus: BO{match.bestOf ?? state?.bestOf ?? 3}</div>
</div>
{/* Countdown / Status */}
{!isOpen && (
<div className="mb-4 text-sm">
<span className="inline-block px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100">
@ -139,100 +231,183 @@ export default function MapVotePanel({ match }: Props) {
</div>
)}
{state && Array.isArray(state.steps) && (
<>
<ol className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{state.steps.map((s, i) => {
const done = !!s.map
const isCurrent = i === state.currentIndex && !state.locked
const actionLabel = s.action === 'ban' ? 'Ban' : s.action === 'pick' ? 'Pick' : 'Decider'
const teamLabel = s.teamId
? (s.teamId === match.teamA.id ? match.teamA.name : match.teamB.name)
: '—'
return (
<li key={i} className={`p-2 rounded border text-sm ${
done
? 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-900/40'
: isCurrent
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-900/40'
: 'border-gray-200 dark:border-neutral-700'
}`}>
<div className="font-medium">
{actionLabel} {s.teamId ? `${teamLabel}` : ''}
</div>
<div className="opacity-80">
{s.map ? fmt(s.map) : (isCurrent ? 'am Zug…' : '—')}
</div>
</li>
)
})}
</ol>
{/* Hauptbereich */}
{state && (
<div className="mt-2 flex items-start gap-4 justify-between">
{/* Links Team A */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56">
{playersA.map((p: MatchPlayer) => (
<MapVoteProfileCard
key={p.user.steamId}
side="A"
name={p.user.name ?? 'Unbekannt'}
avatar={p.user.avatar}
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType}
isLeader={(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) === p.user.steamId}
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) && !state.locked}
/>
))}
</aside>
{/* Karten-Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{mapPool.map((map) => {
const taken = pickedOrBanned.has(map)
const isAvailable = !taken && isMyTurn && isOpen && !state.locked
return (
<button
key={map}
type="button"
onClick={() => isAvailable && handlePickOrBan(map)}
className={[
'relative rounded-lg border px-3 py-2 text-left transition',
taken
? 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700 opacity-60 cursor-not-allowed'
: isAvailable
{/* Mitte Maps untereinander (kompakt + Hold-to-confirm) */}
<main className="max-w-sm flex-shrink-0">
<ul className="flex flex-col gap-1.5">
{mapPool.map((map) => {
const decision = decisionByMap.get(map)
const status = decision?.action ?? null // 'ban' | 'pick' | 'decider' | null
const teamId = decision?.teamId ?? null
const taken = !!status
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
const baseClasses =
'relative flex items-center justify-between gap-2 rounded-md border p-2.5 transition select-none'
const visualClasses =
taken
? (status === 'ban'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-900/40 text-red-800 dark:text-red-200'
: status === 'pick' || status === 'decider'
? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40'
: 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700')
: (isAvailable
? 'bg-white dark:bg-neutral-900 border-blue-400 ring-1 ring-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950 cursor-pointer'
: 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700'
].join(' ')}
disabled={!isAvailable}
title={
taken
? 'Bereits gewählt/gestrichen'
: isAvailable
? 'Jetzt wählen'
: 'Nur der Team-Leader (oder Admin) darf wählen'
}
>
<div className="text-sm font-medium">{fmt(map)}</div>
<div className="text-xs opacity-60">{map}</div>
: 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700')
{taken && (
<span className="absolute top-1 right-1 text-[10px] px-1.5 py-0.5 rounded bg-neutral-800 text-white dark:bg-neutral-200 dark:text-neutral-900">
vergeben
</span>
)}
</button>
)
})}
</div>
const pickedByA = status === 'pick' && teamId === match.teamA?.id
const pickedByB = status === 'pick' && teamId === match.teamB?.id
const showLeftLogo = pickedByA
const showRightLogo = pickedByB
{/* Footer */}
<div className="mt-4 text-sm flex flex-wrap items-center gap-3">
{state.locked ? (
<span className="px-2 py-1 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
Veto abgeschlossen
</span>
) : isOpen ? (
isMyTurn ? (
<span className="px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
Du bist am Zug (Leader/Admin)
const leftLogo = getTeamLogo(match.teamA?.logo)
const rightLogo = getTeamLogo(match.teamB?.logo)
const progress = progressByMap[map] ?? 0
const showProgress = isAvailable && progress > 0 && progress < 1
return (
<li key={map}>
<button
type="button"
className={`${baseClasses} ${visualClasses} w-full text-left`}
disabled={!isAvailable}
title={
taken
? status === 'ban'
? 'Map gebannt'
: status === 'pick'
? 'Map gepickt'
: 'Decider'
: isAvailable
? 'Zum Bestätigen gedrückt halten'
: 'Nur der Team-Leader (oder Admin) darf wählen'
}
onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)}
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
onTouchStart={onTouchStart(map, isAvailable)}
onTouchEnd={onTouchEnd(map)}
onTouchCancel={onTouchEnd(map)}
>
{/* Fortschrittsbalken (unter dem Inhalt) */}
{showProgress && (
<span
aria-hidden
className="absolute inset-y-0 left-0 rounded-md bg-blue-200/60 dark:bg-blue-800/40 pointer-events-none z-0"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
)}
{/* Linkes Logo bei Pick durch Team A */}
{showLeftLogo && (
<img
src={leftLogo}
alt={match.teamA?.name ?? 'Team A'}
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900 z-[1]"
/>
)}
{/* Inhalt mittig (Mapname + technischer Key) */}
<div className="flex-1 min-w-0 relative z-[1] flex flex-col items-center justify-center text-center">
<span className="text-[13px] font-medium truncate">
{fmt(map)}
</span>
{/* rotes X bei Ban über dem Namen */}
{status === 'ban' && (
<span
aria-hidden
className="absolute inset-0 pointer-events-none flex items-center justify-center z-[2]"
>
<svg
viewBox="0 0 24 24"
className="w-8 h-8 opacity-30 text-red-600"
fill="currentColor"
>
<path d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12l-4.9 4.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.9a1 1 0 0 0 1.41-1.41L13.41 12l4.9-4.89a1 1 0 0 0-.01-1.4Z" />
</svg>
</span>
)}
</div>
{/* Rechtes Logo bei Pick durch Team B */}
{showRightLogo && (
<img
src={rightLogo}
alt={match.teamB?.name ?? 'Team B'}
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900 z-[1]"
/>
)}
</button>
</li>
)
})}
</ul>
{/* Footer-Status */}
<div className="mt-3 text-sm flex flex-wrap items-center gap-3">
{state.locked ? (
<span className="px-2 py-1 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
Veto abgeschlossen
</span>
) : (
<span className="px-2 py-1 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
Wartet auf&nbsp;
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
&nbsp;(Leader/Admin)
</span>
)
) : null}
) : isOpen ? (
isMyTurn ? (
<span className="px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
Halte gedrückt, um zu bestätigen
</span>
) : (
<span className="px-2 py-1 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
Wartet auf&nbsp;
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
&nbsp;(Leader/Admin)
</span>
)
) : null}
<Button size="sm" variant="soft" onClick={load} disabled={isLoading}>
{isLoading ? 'Aktualisieren …' : 'Aktualisieren'}
</Button>
</div>
</>
{error && (
<span className="px-2 py-1 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
{error}
</span>
)}
</div>
</main>
{/* Rechts Team B */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56">
{playersB.map((p: MatchPlayer) => (
<MapVoteProfileCard
key={p.user.steamId}
side="B"
name={p.user.name ?? 'Unbekannt'}
avatar={p.user.avatar}
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType}
isLeader={(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === p.user.steamId}
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) && !state.locked}
/>
))}
</aside>
</div>
)}
</div>
)

View File

@ -0,0 +1,98 @@
'use client'
import PremierRankBadge from './PremierRankBadge'
type Side = 'A' | 'B'
type Props = {
side: Side // 'A' = linke Spalte, 'B' = rechte Spalte
name: string
avatar?: string | null
rank?: number // Zahl aus deinen Stats
matchType?: 'premier' | 'competitive' | string
isLeader?: boolean
isActiveTurn?: boolean // pulsiert, wenn dieses Team am Zug ist
onClick?: () => void // optional
}
export default function MapVoteProfileCard({
side,
name,
avatar,
rank = 0,
matchType = 'premier',
isLeader = false,
isActiveTurn = false,
onClick,
}: Props) {
const isRight = side === 'B'
return (
<button
type="button"
onClick={onClick}
className={[
'group relative w-full',
isRight ? 'ml-auto text-right' : 'mr-auto text-left',
].join(' ')}
title={isLeader ? `${name} (Leader)` : name}
>
<div
className={[
'flex items-center gap-3 rounded-xl border bg-white/90 dark:bg-neutral-800/90',
'dark:border-neutral-700 shadow-sm px-3 py-2 transition',
isActiveTurn
? 'ring-2 ring-blue-500/30 shadow-md'
: 'ring-1 ring-black/5 hover:ring-black/10',
isRight ? 'flex-row-reverse' : 'flex-row',
].join(' ')}
>
{/* Avatar */}
<div className="relative shrink-0">
<img
src={avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={name}
className="w-10 h-10 rounded-full object-cover border border-white/60 dark:border-black/20"
/>
{isLeader && (
<span
className={[
'absolute -top-1',
isRight ? '-left-1' : '-right-1',
'inline-grid place-items-center w-5 h-5 rounded-full',
'bg-amber-400 text-white shadow ring-1 ring-black/10',
].join(' ')}
title="Team-Leader"
>
{/* Stern-Icon */}
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="currentColor" aria-hidden>
<path d="m12 17.27 6.18 3.73-1.64-7.03L21 9.24l-7.19-.62L12 2 10.19 8.62 3 9.24l4.46 4.73L5.82 21z"/>
</svg>
</span>
)}
</div>
{/* Text + Rank */}
<div className={['min-w-0', isRight ? 'items-end text-right' : 'items-start text-left', 'flex flex-col'].join(' ')}>
<div className="flex items-center gap-2 max-w-[160px]">
<span className="truncate font-medium text-gray-900 dark:text-neutral-100">
{name}
</span>
<span className="opacity-90">
<PremierRankBadge rank={rank ?? 0} />
</span>
</div>
{isActiveTurn ? (
<span className="mt-0.5 text-[11px] font-medium text-blue-700 dark:text-blue-300">
am Zug
</span>
) : (
<span className="mt-0.5 text-[11px] text-gray-500 dark:text-neutral-400">
bereit
</span>
)}
</div>
</div>
</button>
)
}

View File

@ -5,7 +5,7 @@
*/
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
@ -20,7 +20,9 @@ import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
import type { Match, MatchPlayer } from '../types/match'
import Button from './Button'
import { mapNameMap } from '../lib/mapNameMap'
import MapVoteBanner from './MapVoteBanner'
import MapVotePanel from './MapVotePanel'
import { useSSEStore } from '@/app/lib/useSSEStore'
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
const kdr = (k?: number, d?: number) =>
@ -36,6 +38,7 @@ const adr = (dmg?: number, rounds?: number) =>
/* ─────────────────── Komponente ─────────────────────────────── */
export function MatchDetails ({ match }: { match: Match }) {
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
const router = useRouter()
const isAdmin = !!session?.user?.isAdmin
@ -103,6 +106,26 @@ export function MatchDetails ({ match }: { match: Match }) {
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
)
// Wenn das aktuell angezeigte Match serverseitig gelöscht wurde:
useEffect(() => {
if (!lastEvent) return
if (lastEvent.type !== 'match-deleted') return
const deletedId = lastEvent.payload?.matchId
if (deletedId !== match.id) return
router.replace('/schedule')
}, [lastEvent, match.id, router])
useEffect(() => {
if (!lastEvent) return
if (lastEvent.type !== 'matches-updated') return
// kurz verifizieren, ob es das Match noch gibt
;(async () => {
const r = await fetch(`/api/matches/${match.id}`, { cache: 'no-store' })
if (r.status === 404) router.replace('/schedule')
})()
}, [lastEvent, match.id, router])
return (
<Table>
<ColGroup />
@ -204,9 +227,7 @@ export function MatchDetails ({ match }: { match: Match }) {
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div>
{/* Map-Vote Panel nur anzeigen, wenn freigegeben */}
{isMapVoteOpen && <MapVotePanel match={match} />}
<MapVoteBanner match={match} />
{/* ───────── Team-Blöcke ───────── */}
<div className="border-t pt-4 mt-4 space-y-10">
@ -264,8 +285,8 @@ export function MatchDetails ({ match }: { match: Match }) {
teamA={match.teamA}
teamB={match.teamB}
side={editSide}
initialA={match.teamA.players.map(p => p.user.steamId)}
initialB={match.teamB.players.map(p => p.user.steamId)}
initialA={match.teamA.players.map(p => p.steamId)}
initialB={match.teamB.players.map(p => p.steamId)}
onSaved={() => window.location.reload()}
/>
)}

View File

@ -4,7 +4,7 @@
import { forwardRef, useEffect, useRef, useState } from 'react'
import { useSession } from 'next-auth/react'
import TeamInvitationView from './TeamInvitationView'
import TeamInvitationBanner from './TeamInvitationBanner'
import TeamMemberView from './TeamMemberView'
import NoTeamView from './NoTeamView'
import LoadingSpinner from '@/app/components/LoadingSpinner'
@ -187,7 +187,7 @@ function TeamCardComponent(_: Props, _ref: any) {
<>
<div className="space-y-4">
{pendingInvitations.map(inv => (
<TeamInvitationView
<TeamInvitationBanner
key={inv.id}
invitation={inv}
notificationId={inv.id}

View File

@ -1,4 +1,4 @@
// TeamInvitationView.tsx
// TeamInvitationBanner.tsx
'use client'
import { useState } from 'react'
@ -15,7 +15,7 @@ type Props = {
onMarkAsRead: (id: string) => Promise<void>
}
export default function TeamInvitationView({
export default function TeamInvitationBanner({
invitation,
notificationId,
onAction,

View File

@ -24,10 +24,6 @@ function getRoundedDate() {
return now
}
function getTeamLogo(logo?: string | null) {
return logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
}
export default function MatchesAdminManager() {
const [teams, setTeams] = useState<any[]>([])
const [matches, setMatches] = useState<any[]>([])

View File

@ -20,6 +20,10 @@ export const SSE_EVENT_TYPES = [
'team-joined',
'expired-sharecode',
'team-invite-revoked',
'map-vote-updated',
'match-created',
'matches-updated',
'match-deleted',
] as const;
export type SSEEventType = typeof SSE_EVENT_TYPES[number];

View File

@ -0,0 +1,20 @@
// /app/match-details/[matchId]/map-vote/page.tsx
import { notFound } from 'next/navigation'
import Card from '@/app/components/Card'
import MapVotePanel from '@/app/components/MapVotePanel'
async function loadMatch(id: string) {
const r = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/api/matches/${id}`, { cache: 'no-store' })
if (!r.ok) return null
return r.json()
}
export default async function MapVotePage({ params }: { params: { matchId: string } }) {
const match = await loadMatch(params.matchId)
if (!match) return notFound()
return (
<Card maxWidth="auto">
<MapVotePanel match={match} />
</Card>
)
}

View File

@ -1,21 +1,24 @@
// z.B. /app/types/mapvote.ts
export type VetoAction = 'ban' | 'pick' | 'decider';
// /types/mapvote.ts
import type { Player } from './team'
export type MapVetoStep = {
index: number;
action: VetoAction; // 'ban' | 'pick' | 'decider'
teamId?: string | null; // wer ist dran (bei 'decider' meist null)
map?: string | null; // gewählte Map (wenn ausgeführt)
byUser?: string | null; // steamId des Klickers
at?: string | null; // ISO-Zeitpunkt
};
order: number
action: 'ban'|'pick'|'decider'
teamId: string | null
map: string | null
chosenAt: string | null
chosenBy: string | null
}
export type MapVetoState = {
matchId: string;
bestOf: 3|5;
mapPool: string[]; // z.B. ['de_inferno','de_mirage',...]
steps: MapVetoStep[]; // komplette Reihenfolge inkl. 'decider'
currentIndex: number; // nächste auszuführende Stufe
opensAt: string; // ISO: matchDate - 60min
locked: boolean; // fertig / gesperrt
};
bestOf: number
mapPool: string[]
currentIndex: number
locked: boolean
opensAt: string | null
steps: MapVetoStep[]
teams?: {
teamA: { id: string | null; name?: string | null; logo?: string | null; leader: Player | null }
teamB: { id: string | null; name?: string | null; logo?: string | null; leader: Player | null }
}
}

View File

@ -18,7 +18,7 @@ export type Team = {
id: string
name?: string | null
logo?: string | null
leader?: string | null
leader?: Player
activePlayers: Player[]
inactivePlayers: Player[]
invitedPlayers: InvitedPlayer[]
@ -28,6 +28,6 @@ export type TeamMatches = {
id: string
name?: string | null
logo?: string | null
leader?: string | null
players: MatchPlayer[]
leader?: Player
players: Player[]
}