updated
This commit is contained in:
parent
5d0150d903
commit
bdb5cbb4e1
308
package-lock.json
generated
308
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
191
src/app/components/MapVoteBanner.tsx
Normal file
191
src/app/components/MapVoteBanner.tsx
Normal 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)}`
|
||||
}
|
||||
@ -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
|
||||
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
|
||||
(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
|
||||
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
|
||||
(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>
|
||||
)
|
||||
|
||||
98
src/app/components/MapVoteProfileCard.tsx
Normal file
98
src/app/components/MapVoteProfileCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
@ -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[]>([])
|
||||
|
||||
@ -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];
|
||||
|
||||
20
src/app/match-details/[matchId]/map-vote/page.tsx
Normal file
20
src/app/match-details/[matchId]/map-vote/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user