This commit is contained in:
Linrador 2025-08-17 23:22:06 +02:00
parent a832abff2e
commit dfc3e6bf99
141 changed files with 3537 additions and 1038 deletions

9
.env
View File

@ -15,4 +15,11 @@ STEAMCMD_PATH=C:\Users\Rother\Desktop\dev\ironie\steamcmd\steamcmd.exe
NEXTAUTH_SECRET=ironieopen
NEXTAUTH_URL=http://localhost:3000
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
PTERODACTYL_CLIENT_API=ptlc_6NXqjxieIekaULga2jmuTPyPwdziigT82PRbrg3G4S7
PTERO_PANEL_URL=https://panel.ironieopen.de
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
PTERO_SERVER_SFTP_USER=army.37a11489
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
PTERO_SERVER_ID=37a11489

View File

@ -1,4 +1,4 @@
import type { NextConfig } from "next";
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
@ -11,6 +11,20 @@ const nextConfig: NextConfig = {
},
],
},
};
export default nextConfig;
serverExternalPackages: ['ssh2', 'ssh2-sftp-client'],
webpack: (config, { isServer }) => {
if (isServer) {
const externals = Array.isArray(config.externals) ? config.externals : []
externals.push({
ssh2: 'commonjs ssh2',
'ssh2-sftp-client': 'commonjs ssh2-sftp-client',
} as any)
;(config as any).externals = externals
}
return config
},
}
export default nextConfig

207
package-lock.json generated
View File

@ -30,6 +30,7 @@
"ky": "^1.8.2",
"lodash": "^4.17.21",
"lzma-native": "^8.0.6",
"nanoid": "^5.1.5",
"next": "15.3.0",
"next-auth-steam": "^0.4.0",
"next-intl": "^4.3.4",
@ -43,6 +44,7 @@
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"ssh2-sftp-client": "^12.0.1",
"vanilla-calendar-pro": "^3.0.4",
"zustand": "^5.0.3"
},
@ -54,6 +56,7 @@
"@types/node-cron": "^3.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ssh2-sftp-client": "^9.0.5",
"@types/ws": "^8.18.1",
"eslint": "^9",
"eslint-config-next": "15.3.0",
@ -2096,6 +2099,43 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18"
}
},
"node_modules/@types/ssh2-sftp-client": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.5.tgz",
"integrity": "sha512-cpUO6okDusnfLw2hnmaBiomlSchIWNVcCdpywLRsg/h9Q1TTiUSrzhkn5sJeeyTM8h6xRbZEZZjgWtUXFDogHg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ssh2": "^1.0.0"
}
},
"node_modules/@types/ssh2/node_modules/@types/node": {
"version": "18.19.123",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.123.tgz",
"integrity": "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/ssh2/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -2820,6 +2860,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@ -2897,6 +2946,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -2921,6 +2979,21 @@
"node": ">=8"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -3173,6 +3246,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
@ -3200,6 +3288,20 @@
"node": ">= 0.6"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@ -5871,10 +5973,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz",
"integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==",
"funding": [
{
"type": "github",
@ -5883,10 +5992,10 @@
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
@ -6052,6 +6161,24 @@
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -6584,6 +6711,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/preact": {
"version": "10.26.5",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.5.tgz",
@ -7020,6 +7165,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@ -7243,6 +7394,40 @@
"node": ">=0.10.0"
}
},
"node_modules/ssh2": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz",
"integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.20.0"
}
},
"node_modules/ssh2-sftp-client": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.0.1.tgz",
"integrity": "sha512-ICJ1L2PmBel2Q2ctbyxzTFZCPKSHYYD6s2TFZv7NXmZDrDNGk8lHBb/SK2WgXLMXNANH78qoumeJzxlWZqSqWg==",
"license": "Apache-2.0",
"dependencies": {
"concat-stream": "^2.0.0",
"ssh2": "^1.16.0"
},
"engines": {
"node": ">=18.20.4"
},
"funding": {
"type": "individual",
"url": "https://square.link/u/4g7sPflL"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@ -7636,6 +7821,12 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -7727,6 +7918,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",

View File

@ -34,6 +34,7 @@
"ky": "^1.8.2",
"lodash": "^4.17.21",
"lzma-native": "^8.0.6",
"nanoid": "^5.1.5",
"next": "15.3.0",
"next-auth-steam": "^0.4.0",
"next-intl": "^4.3.4",
@ -47,6 +48,7 @@
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"ssh2-sftp-client": "^12.0.1",
"vanilla-calendar-pro": "^3.0.4",
"zustand": "^5.0.3"
},
@ -58,6 +60,7 @@
"@types/node-cron": "^3.0.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ssh2-sftp-client": "^9.0.5",
"@types/ws": "^8.18.1",
"eslint": "^9",
"eslint-config-next": "15.3.0",

View File

@ -45,8 +45,18 @@ model User {
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
status UserStatus @default(offline) // 👈 neu
lastActiveAt DateTime? // optional: wann zuletzt aktiv
}
enum UserStatus {
online
away
offline
}
model Team {
id String @id @default(uuid())
name String @unique

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -0,0 +1,151 @@
// /app/api/matches/[id]/export-to-sftp/route.ts
import { NextResponse } from 'next/server'
import { randomBytes } from 'crypto'
export const runtime = 'nodejs' // KEIN edge
export const dynamic = 'force-dynamic'
type MapVoteStep = {
action: 'ban' | 'pick' | 'decider'
map?: string | null
teamId?: string | null
}
type MapVoteState = {
bestOf?: number
steps: MapVoteStep[]
mapVisuals?: Record<string, { label?: string; bg?: string }>
teams?: {
teamA?: { id?: string | null, name?: string | null, players?: Array<{ steamId: string, name?: string | null }> }
teamB?: { id?: string | null, name?: string | null, players?: Array<{ steamId: string, name?: string | null }> }
}
locked?: boolean
}
type PlayerLike = { user?: { steamId: string, name?: string | null }, steamId?: string, name?: string | null }
type MatchLike = {
id: string | number
bestOf?: number
teamA?: { name?: string | null, players?: PlayerLike[] | any[] }
teamB?: { name?: string | null, players?: PlayerLike[] | any[] }
}
function sanitizeFilePart(s?: string | null) {
return (s ?? 'team').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
function playersMapFromList(list: PlayerLike[] | any[] | undefined) {
const out: Record<string, string> = {}
for (const p of list ?? []) {
const sid = (p?.user?.steamId ?? (p as any)?.steamId) as string | undefined
if (!sid) continue
const name = (p?.user?.name ?? p?.name ?? 'Player') as string
out[sid] = name
}
return out
}
function toDeMapName(key: string) {
if (key.startsWith('de_')) return key
return `de_${key}`
}
function buildMatchJson(match: MatchLike, state: MapVoteState) {
const bestOf = match.bestOf ?? state.bestOf ?? 3
// Nur Picks + Decider in Reihenfolge (ohne Lücken)
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
// maplist
const maplist = chosen.slice(0, bestOf).map(s => toDeMapName(s.map!))
// einfache Sides-Logik (Beispiel): first two maps start CT for Team1/Team2, last knife
const map_sides = maplist.map((_, i) => {
if (i === maplist.length - 1) return 'knife'
return i % 2 === 0 ? 'team1_ct' : 'team2_ct'
})
const team1Name = match.teamA?.name ?? 'Team_1'
const team2Name = match.teamB?.name ?? 'Team_2'
const team1Players = playersMapFromList(match.teamA?.players)
const team2Players = playersMapFromList(match.teamB?.players)
const payload = {
matchid: Number(match.id) || 0,
team1: { name: team1Name, players: team1Players },
team2: { name: team2Name, players: team2Players },
num_maps: bestOf,
maplist,
map_sides,
spectators: { players: {} as Record<string, string> }, // optional
clinch_series: true,
players_per_team: 5,
cvars: {
hostname: `Iron:e Open 4 | ${team1Name} vs ${team2Name}`,
mp_friendlyfire: '1',
},
}
return payload
}
export async function POST(req: Request, { params }: { params: { matchId: string } }) {
try {
const SFTPClient = (await import('ssh2-sftp-client')).default // dyn. import
const { match, state } = (await req.json()) as { match: MatchLike, state: MapVoteState }
if (!match || !state?.locked) {
return NextResponse.json({ error: 'Ungültige Daten oder Voting nicht abgeschlossen.' }, { status: 400 })
}
const bestOf = match.bestOf ?? state.bestOf ?? 3
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
if (chosen.length < bestOf) {
return NextResponse.json({ error: 'Es sind noch nicht alle Maps gewählt.' }, { status: 400 })
}
// JSON bauen
const json = buildMatchJson(match, state)
const jsonStr = JSON.stringify(json, null, 2)
// Dateiname (wie gewünscht)
const team1 = sanitizeFilePart(match.teamA?.name)
const team2 = sanitizeFilePart(match.teamB?.name)
const rid = randomBytes(3).toString('hex') // 6 hex chars
const filename = `team_${team1}_vs_team_${team2}_${rid}.json`
// SFTP Credentials
const url = process.env.PTERO_SERVER_SFTP_URL || '' // z.B. "sftp://your.host:22" oder "your.host:22" oder nur Host
const user = process.env.PTERO_SERVER_SFTP_USER
const pass = process.env.PTERO_SERVER_SFTP_PASSWORD
if (!url || !user || !pass) {
return NextResponse.json({ error: 'SFTP-Umgebungsvariablen fehlen.' }, { status: 500 })
}
// host/port extrahieren
let host = url
let port = 22
try {
const u = new URL(url.includes('://') ? url : `sftp://${url}`)
host = u.hostname
port = Number(u.port) || 22
} catch {
// Fallback: "host:port" oder nur host
const [h, p] = url.split(':')
host = h ?? url
port = p ? Number(p) : 22
}
// Upload
const sftp = new SFTPClient()
await sftp.connect({ host, port, username: user, password: pass })
const remotePath = `/game/csgo/${filename}`
await sftp.put(Buffer.from(jsonStr, 'utf8'), remotePath)
await sftp.end()
return NextResponse.json({ ok: true, remotePath, filename })
} catch (err: any) {
console.error('[export-to-sftp] error:', err)
return NextResponse.json({ error: err?.message ?? 'Upload fehlgeschlagen' }, { status: 500 })
}
}

View File

@ -1,106 +1,133 @@
// /app/api/matches/[matchId]/mapvote/reset/route.ts
// /app/api/matches/[id]/mapvote/reset/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
import { createHash } from 'crypto'
// ---- Pool aus MAP_OPTIONS ableiten (nur "de_*", ohne Sonderkarten) ----
const MAP_POOL: string[] = MAP_OPTIONS
.filter(m => m.key.startsWith('de_') && m.key !== 'lobby_mapvote')
.map(m => m.key)
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// identisch zu mapvote-Route
function voteOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
const base = match.matchDate ?? match.demoDate ?? new Date()
return new Date(base.getTime() - 60 * 60 * 1000)
function shapeState(vote: any) {
const ACTION_MAP = { BAN: 'ban', PICK: 'pick', DECIDER: 'decider' } as const
const steps = [...vote.steps]
.sort((a, b) => a.order - b.order)
.map((s: any) => ({
order : s.order,
action : ACTION_MAP[s.action as keyof typeof ACTION_MAP],
teamId : s.teamId,
map : s.map,
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
chosenBy: s.chosenBy ?? null,
}))
return {
bestOf : vote.bestOf,
mapPool : vote.mapPool as string[],
currentIndex: vote.currentIdx,
locked : vote.locked as boolean,
opensAt : vote.opensAt ? new Date(vote.opensAt).toISOString() : null,
steps,
adminEdit: vote.adminEditingBy
? {
enabled: true,
by: vote.adminEditingBy as string,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
}
: { enabled: false, by: null, since: null },
}
}
// buildSteps so umbauen, dass die Reihenfolge (Startteam) variabel ist
function buildSteps(bestOf: number, firstId: string, secondId: string) {
if (bestOf === 3) {
return [
{ order: 0, action: MapVoteAction.BAN, teamId: firstId },
{ order: 1, action: MapVoteAction.BAN, teamId: secondId },
{ order: 2, action: MapVoteAction.PICK, teamId: firstId },
{ order: 3, action: MapVoteAction.PICK, teamId: secondId },
{ order: 4, action: MapVoteAction.BAN, teamId: firstId },
{ order: 5, action: MapVoteAction.BAN, teamId: secondId },
{ order: 6, action: MapVoteAction.DECIDER, teamId: null },
] as const
function buildMapVisuals(matchId: string, mapPool: string[]) {
const visuals: Record<string, { label: string; bg: string; images?: string[] }> = {}
for (const key of mapPool) {
const opt = MAP_OPTIONS.find(o => o.key === key)
const label = opt?.label ?? key
const imgs = opt?.images ?? []
let bg = `/assets/img/maps/${key}/1.jpg`
if (imgs.length > 0) {
const h = createHash('sha256').update(`${matchId}:${key}`).digest('hex')
const n = parseInt(h.slice(0, 8), 16)
const idx = n % imgs.length
bg = imgs[idx]
}
visuals[key] = { label, bg }
}
// BO5
return [
{ order: 0, action: MapVoteAction.BAN, teamId: firstId },
{ order: 1, action: MapVoteAction.BAN, teamId: secondId },
{ order: 2, action: MapVoteAction.PICK, teamId: firstId },
{ order: 3, action: MapVoteAction.PICK, teamId: secondId },
{ order: 4, action: MapVoteAction.PICK, teamId: firstId },
{ order: 5, action: MapVoteAction.PICK, teamId: secondId },
{ order: 6, action: MapVoteAction.PICK, teamId: firstId },
] as const
return visuals
}
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.isAdmin) {
return NextResponse.json({ message: 'Nicht autorisiert' }, { status: 403 })
}
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
const match = await prisma.match.findUnique({
where: { id: matchId },
select: {
id: true,
bestOf: true,
matchDate: true,
demoDate: true,
teamA: { select: { id: true } },
teamB: { select: { id: true } },
mapVote: { select: { id: true } },
},
})
if (!match?.teamA?.id || !match?.teamB?.id) {
return NextResponse.json({ message: 'Match/Teams nicht gefunden' }, { status: 404 })
}
const bestOf = match.bestOf ?? 3
// ---- Zufälliges Startteam bestimmen ----
const firstId = Math.random() < 0.5 ? match.teamA.id : match.teamB.id
const secondId = firstId === match.teamA.id ? match.teamB.id : match.teamA.id
const stepsDef = buildSteps(bestOf, firstId, secondId)
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
await prisma.$transaction(async (tx) => {
if (match.mapVote?.id) {
await tx.mapVoteStep.deleteMany({ where: { voteId: match.mapVote.id } })
await tx.mapVote.delete({ where: { matchId } })
try {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: { mapVote: { include: { steps: true } } },
})
if (!match?.mapVote) {
return NextResponse.json({ message: 'Map-Vote nicht gefunden' }, { status: 404 })
}
await tx.mapVote.create({
data: {
matchId,
bestOf,
mapPool: MAP_POOL, // <- aus MAP_OPTIONS
currentIdx: 0,
locked: false,
opensAt,
steps: {
create: stepsDef.map(s => ({
order: s.order,
action: s.action,
teamId: s.teamId,
})),
},
},
})
})
// optional: nur Admins erlauben falls gewünscht:
// if (!me.isAdmin) return NextResponse.json({ message: 'Nur Admins dürfen resetten' }, { status: 403 })
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
return NextResponse.json({ ok: true })
const vote = match.mapVote
// Reset in einer Transaktion
await prisma.$transaction(async (tx) => {
// alle Steps zurücksetzen
await Promise.all(
vote.steps.map(s =>
tx.mapVoteStep.update({
where: { id: s.id },
data : { map: null, chosenAt: null, chosenBy: null },
})
)
)
// Vote Zustand zurücksetzen
await tx.mapVote.update({
where: { id: vote.id },
data : {
currentIdx: 0,
locked: false,
adminEditingBy: null,
adminEditingSince: null,
},
})
})
const updated = await prisma.mapVote.findUnique({
where: { id: vote.id },
include: { steps: true },
})
if (!updated) {
return NextResponse.json({ message: 'Reset fehlgeschlagen' }, { status: 500 })
}
// Events: zuerst map-vote-updated (UI neu laden)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
// dann map-vote-reset (globale Overlays schließen, lokale Flags löschen)
await sendServerSSEMessage({ type: 'map-vote-reset', matchId })
const mapVisuals = buildMapVisuals(match.id, updated.mapPool)
return NextResponse.json(
{ ...shapeState(updated), mapVisuals },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (e) {
console.error('[map-vote-reset][POST] error', e)
return NextResponse.json({ message: 'Reset fehlgeschlagen' }, { status: 500 })
}
}

View File

@ -5,9 +5,11 @@ import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { randomInt } from 'crypto'
import { randomInt, createHash, randomBytes } from 'crypto'
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
import { createHash } from 'crypto'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/* -------------------- Konstanten -------------------- */
@ -118,122 +120,74 @@ function shapePlayer(p: any) {
}
}
// Base-URL aus Request ableiten (lokal/proxy-fähig)
function getBaseUrl(req: NextRequest | NextResponse) {
const proto = (req.headers.get('x-forwarded-proto') || 'http').split(',')[0].trim()
const host = (req.headers.get('x-forwarded-host') || req.headers.get('host') || '').split(',')[0].trim()
return `${proto}://${host}`
}
async function fetchTeamApi(teamId: string | null | undefined, req: NextRequest) {
if (!teamId) return null
const base = getBaseUrl(req)
const url = `${base}/api/team/${teamId}`
try {
const r = await fetch(url, {
cache: 'no-store',
headers: {
'x-forwarded-proto': req.headers.get('x-forwarded-proto') || '',
'x-forwarded-host' : req.headers.get('x-forwarded-host') || '',
}
})
if (!r.ok) return null
const json = await r.json()
return json as {
id: string
name?: string | null
logo?: string | null
leader?: string | null
activePlayers: any[]
inactivePlayers: any[]
invitedPlayers: any[]
}
} catch {
return null
// Teams-Payload (mit Spielern) zusammenbauen
function shapeUser(u: any) {
if (!u) return null
return {
steamId : u.steamId,
name : u.name ?? '',
avatar : u.avatar ?? '',
location : u.location ?? undefined,
premierRank: u.premierRank ?? undefined,
isAdmin : u.isAdmin ?? undefined,
}
}
// Teams-Payload (mit Spielern) zusammenbauen
async function buildTeamsPayload(match: any, req: NextRequest) {
const [teamAApi, teamBApi] = await Promise.all([
fetchTeamApi(match.teamA?.id, req),
fetchTeamApi(match.teamB?.id, req),
])
const teamAPlayers = (teamAApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean)
const teamBPlayers = (teamBApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean)
function buildTeamsPayloadFromMatch(match: any) {
const teamAPlayers = (match.teamAUsers ?? []).map(shapeUser).filter(Boolean)
const teamBPlayers = (match.teamBUsers ?? []).map(shapeUser).filter(Boolean)
return {
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader : resolveLeaderPlayer(match.teamA, teamAApi),
leader : shapeLeader(match.teamA?.leader ?? null),
players: teamAPlayers,
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader : resolveLeaderPlayer(match.teamB, teamBApi),
leader : shapeLeader(match.teamB?.leader ?? null),
players: teamBPlayers,
},
}
}
// Leader bevorzugt aus Match-Relation; Fallback über Team-API
function resolveLeaderPlayer(matchTeam: any | null | undefined, teamApi: any | null) {
const leaderFromMatch = shapeLeader(matchTeam?.leader ?? null)
if (leaderFromMatch) return leaderFromMatch
const leaderId: string | null = teamApi?.leader ?? null
if (!leaderId) return null
const pool: any[] = [
...(teamApi?.activePlayers ?? []),
...(teamApi?.inactivePlayers ?? []),
...(teamApi?.invitedPlayers ?? []),
]
const found = pool.find(p => p?.steamId === leaderId)
return shapePlayer(found) ?? { steamId: leaderId, name: '', avatar: '' }
}
async function ensureVote(matchId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA : {
teamA: {
include: {
leader: {
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
isAdmin: true,
steamId: true, name: true, avatar: true, location: true,
premierRank: true, isAdmin: true,
}
}
}
},
teamB : {
teamB: {
include: {
leader: {
select: {
steamId: true,
name: true,
avatar: true,
location: true,
premierRank: true,
isAdmin: true,
steamId: true, name: true, avatar: true, location: true,
premierRank: true, isAdmin: true,
}
}
}
},
// 👉 Spieler direkt am Match laden:
teamAUsers: true,
teamBUsers: true,
// optional zusätzlich:
players: { include: { user: true } }, // falls du MatchPlayer brauchst
mapVote: { include: { steps: true } },
},
})
if (!match) return { match: null, vote: null }
// Bereits vorhanden?
@ -241,7 +195,7 @@ async function ensureVote(matchId: string) {
// Neu anlegen
const bestOf = match.bestOf ?? 3
const mapPool = MAP_OPTIONS.map(m => m.key)
const mapPool = MAP_OPTIONS.filter(m => m.active).map(m => m.key)
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const firstIsA = (typeof randomInt === 'function') ? randomInt(0, 2) === 0 : Math.random() < 0.5
@ -294,11 +248,169 @@ function buildMapVisuals(matchId: string, mapPool: string[]) {
bg = imgs[idx]
}
visuals[key] = { label, bg } // images optional mitgeben: { label, bg, images: imgs }
visuals[key] = { label, bg }
}
return visuals
}
function uniq<T>(arr: T[]) {
return Array.from(new Set(arr))
}
function collectParticipants(match: any): string[] {
const fromMatchPlayers =
(match.players ?? [])
.map((mp: any) => mp?.user?.steamId)
.filter(Boolean)
const fromTeamUsersA = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
const fromTeamUsersB = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
const fromActiveA = Array.isArray(match.teamA?.activePlayers) ? match.teamA.activePlayers : []
const fromActiveB = Array.isArray(match.teamB?.activePlayers) ? match.teamB.activePlayers : []
const leaderA = match.teamA?.leader?.steamId ? [match.teamA.leader.steamId] : []
const leaderB = match.teamB?.leader?.steamId ? [match.teamB.leader.steamId] : []
return uniq<string>([
...fromMatchPlayers,
...fromTeamUsersA, ...fromTeamUsersB,
...fromActiveA, ...fromActiveB,
...leaderA, ...leaderB,
])
}
/* ---------- Export-Helfer ---------- */
type PlayerLike = { user?: { steamId: string, name?: string | null }, steamId?: string, name?: string | null }
type MatchLike = {
id: string | number
bestOf?: number
teamA?: { name?: string | null, players?: PlayerLike[] | any[] }
teamB?: { name?: string | null, players?: PlayerLike[] | any[] }
}
type MapVoteStep = { action: 'ban' | 'pick' | 'decider', map?: string | null, teamId?: string | null }
type MapVoteStateForExport = { bestOf?: number, steps: MapVoteStep[], locked?: boolean }
function sanitizeFilePart(s?: string | null) {
return (s ?? 'team').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
function playersMapFromList(list: PlayerLike[] | any[] | undefined) {
const out: Record<string, string> = {}
for (const p of list ?? []) {
const sid = (p?.user?.steamId ?? (p as any)?.steamId) as string | undefined
if (!sid) continue
const name = (p?.user?.name ?? p?.name ?? 'Player') as string
out[sid] = name
}
return out
}
function toDeMapName(key: string) {
if (key.startsWith('de_')) return key
return `de_${key}`
}
function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
const bestOf = match.bestOf ?? state.bestOf ?? 3
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
const maplist = chosen.slice(0, bestOf).map(s => toDeMapName(s.map!))
const map_sides = maplist.map((_, i) => {
if (i === maplist.length - 1) return 'knife'
return i % 2 === 0 ? 'team1_ct' : 'team2_ct'
})
const team1Name = match.teamA?.name ?? 'Team_1'
const team2Name = match.teamB?.name ?? 'Team_2'
const team1Players = playersMapFromList(match.teamA?.players)
const team2Players = playersMapFromList(match.teamB?.players)
return {
matchid: "",
team1: { name: team1Name, players: team1Players },
team2: { name: team2Name, players: team2Players },
num_maps: bestOf,
maplist,
map_sides,
spectators: { players: {} as Record<string, string> },
clinch_series: true,
players_per_team: 5,
cvars: {
hostname: `Iron:e Open 4 | ${team1Name} vs ${team2Name}`,
mp_friendlyfire: '1',
},
}
}
async function exportMatchToSftpDirect(match: any, vote: any) {
try {
const SFTPClient = (await import('ssh2-sftp-client')).default // dyn. import
const mLike: MatchLike = {
id: match.id,
bestOf: match.bestOf ?? vote.bestOf ?? 3,
teamA: { name: match.teamA?.name ?? 'Team_1', players: match.teamAUsers ?? [] },
teamB: { name: match.teamB?.name ?? 'Team_2', players: match.teamBUsers ?? [] },
}
const sLike: MapVoteStateForExport = {
bestOf: vote.bestOf,
steps: [...vote.steps]
.sort((a: any, b: any) => a.order - b.order)
.map((s: any) => ({ action: mapActionToApi(s.action), map: s.map, teamId: s.teamId })),
locked: vote.locked,
}
if (!sLike.locked) return // nur exportieren, wenn locked
const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
if (chosen.length < bestOf) return // vollständig sicherstellen
const json = buildMatchJson(mLike, sLike)
const jsonStr = JSON.stringify(json, null, 2)
const filename = `${match.id}.json`
const url = process.env.PTERO_SERVER_SFTP_URL || ''
const user = process.env.PTERO_SERVER_SFTP_USER
const pass = process.env.PTERO_SERVER_SFTP_PASSWORD
if (!url || !user || !pass) {
throw new Error('SFTP-Umgebungsvariablen fehlen (PTERO_SERVER_SFTP_URL, _USER, _PASSWORD).')
}
let host = url
let port = 22
try {
const u = new URL(url.includes('://') ? url : `sftp://${url}`)
host = u.hostname
port = Number(u.port) || 22
} catch {
const [h, p] = url.split(':')
host = h ?? url
port = p ? Number(p) : 22
}
const sftp = new SFTPClient()
await sftp.connect({ host, port, username: user, password: pass })
const remotePath = `/game/csgo/${filename}`
await sftp.put(Buffer.from(jsonStr, 'utf8'), remotePath)
await sftp.end()
console.log(`[mapvote] Export OK → ${remotePath}`)
} catch (err) {
console.error('[mapvote] Export fehlgeschlagen:', err)
}
}
/* ---------- kleine Helfer für match-ready Payload ---------- */
function deriveChosenSteps(vote: any) {
const steps = [...vote.steps].sort((a: any, b: any) => a.order - b.order)
return steps.filter((s: any) => (s.action === 'PICK' || s.action === 'DECIDER') && s.map)
}
/* -------------------- GET -------------------- */
export async function GET(req: NextRequest, { params }: { params: { matchId: string } }) {
@ -309,7 +421,7 @@ export async function GET(req: NextRequest, { params }: { params: { matchId: str
const { match, vote } = await ensureVote(matchId)
if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const teams = await buildTeamsPayload(match, req)
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, vote.mapPool)
return NextResponse.json(
@ -350,7 +462,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
const teams = await buildTeamsPayload(match, req)
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated.mapPool)
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
@ -380,9 +492,30 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
const teams = await buildTeamsPayload(match, req)
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
// match-ready senden (erste Map + Teilnehmer)
if (updated?.locked) {
const chosen = deriveChosenSteps(updated)
const first = chosen[0]
const key = first?.map ?? null
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp'
const participants = collectParticipants(match)
await sendServerSSEMessage({
type: 'match-ready',
matchId,
firstMap: { key, label, bg },
participants,
});
// Export serverseitig
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
}
@ -412,9 +545,30 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
const teams = await buildTeamsPayload(match, req)
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
// match-ready senden
if (updated?.locked) {
const chosen = deriveChosenSteps(updated)
const first = chosen[0]
const key = first?.map ?? null
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp'
const participants = collectParticipants(match)
await sendServerSSEMessage({
type: 'match-ready',
matchId,
firstMap: { key, label, bg },
participants,
});
// Export serverseitig
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
}
@ -483,9 +637,29 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
const teams = await buildTeamsPayload(match, req)
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
// Falls durch diesen Schritt locked wurde → Export & match-ready
if (updated?.locked) {
const chosen = deriveChosenSteps(updated)
const first = chosen[0]
const key = first?.map ?? null
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp'
const participants = collectParticipants(match)
await sendServerSSEMessage({
type: 'match-ready',
matchId,
firstMap: { key, label, bg },
participants,
});
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
} catch (e) {
console.error('[map-vote][POST] error', e)

View File

@ -0,0 +1,39 @@
import { NextResponse } from 'next/server'
const PANEL = process.env.PTERO_PANEL_URL!
const KEY = process.env.PTERODACTYL_CLIENT_API!
const SID = process.env.PTERO_SERVER_ID!
export async function POST(req: Request) {
if (!PANEL || !KEY || !SID) {
return NextResponse.json({ error: 'Pterodactyl env missing' }, { status: 500 })
}
try {
const { command } = await req.json() as { command?: string }
if (!command || typeof command !== 'string') {
return NextResponse.json({ error: 'command required' }, { status: 400 })
}
const r = await fetch(`${PANEL}/api/client/servers/${SID}/command`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ command }),
// wichtig: keine Caching-Probleme
cache: 'no-store',
})
if (!r.ok) {
const msg = await r.text().catch(() => '')
return NextResponse.json({ error: 'Pterodactyl error', details: msg }, { status: r.status })
}
return NextResponse.json({ ok: true })
} catch (e: any) {
return NextResponse.json({ error: e?.message ?? 'unknown error' }, { status: 500 })
}
}

View File

@ -7,20 +7,16 @@ export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
/* ───── Request-Body ───── */
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
if (!teamname?.trim()) {
return NextResponse.json({ message: 'Teamname fehlt.' }, { status: 400 })
}
// Optionaler Vorab-Check (Unique-Constraint sollte zusätzlich auf DB-Ebene existieren)
const dup = await prisma.team.findFirst({ where: { name: teamname } })
if (dup) {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
}
/* ───── Team anlegen ───── */
const newTeam = await prisma.team.create({
data: {
name: teamname,
@ -30,22 +26,20 @@ export async function POST(req: NextRequest) {
},
})
/* ───── Leader verknüpfen + Notification ───── */
if (leader) {
const user = await prisma.user.findUnique({ where: { steamId: leader } })
if (!user) {
// Rollback Team und Fehler
await prisma.team.delete({ where: { id: newTeam.id } })
return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 })
}
// User an Team hängen
// user dem Team zuordnen
await prisma.user.update({
where: { steamId: leader },
data: { teamId: newTeam.id },
})
// Persistente Notification
// 🔔 (optional) persistente Notification
const note = await prisma.notification.create({
data: {
steamId: leader,
@ -56,9 +50,9 @@ export async function POST(req: NextRequest) {
},
})
// ➜ Sofortige Live-Zustellung an den Leader
// ➜ Sofort an Notification-Center
await sendServerSSEMessage({
type: 'notification', // wichtig fürs NotificationCenter
type: 'notification',
targetUserIds: [leader],
message: note.message,
id: note.id,
@ -66,9 +60,15 @@ export async function POST(req: NextRequest) {
actionData: note.actionData ?? undefined,
createdAt: note.createdAt.toISOString(),
})
// ✅ ➜ HIER: Self-Refresh für den Ersteller
await sendServerSSEMessage({
type: 'self-updated', // <— stelle sicher, dass dein Client darauf hört
targetUserIds: [leader],
})
}
/* ───── Optionale Info: Broadcast (falls du den Eventtyp nutzt) ───── */
// (Optional) Broadcasts
await sendServerSSEMessage({
type: 'team-created',
title: 'Team erstellt',
@ -76,9 +76,8 @@ export async function POST(req: NextRequest) {
teamId: newTeam.id,
})
/* ───── Failsafe/Listen-Refresh für alle Clients ───── */
await sendServerSSEMessage({
type: 'team-updated', // von deinen Views bereits beobachtet
type: 'team-updated',
teamId: newTeam.id,
})
@ -87,7 +86,6 @@ export async function POST(req: NextRequest) {
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (error: any) {
// Unique-Constraint sauber abfangen (falls zwei Requests gleichzeitig kommen)
if (error?.code === 'P2002') {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
}

View File

@ -1,4 +1,4 @@
// src/app/api/team/list/route.ts
// src/app/api/teams/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import type { Player } from '@/app/types/team'
@ -71,7 +71,7 @@ export async function GET() {
return NextResponse.json({ teams: result })
} catch (err) {
console.error('GET /api/team/list failed:', err)
console.error('GET /api/teams failed:', err)
return NextResponse.json(
{ message: 'Interner Serverfehler' },
{ status: 500 },

View File

@ -1,4 +1,4 @@
// /app/api/user/[steamId]/route.ts
// /src/app/api/user/[steamId]/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
@ -7,7 +7,6 @@ export async function GET(
{ params }: { params: { steamId: string } }
) {
const steamId = params.steamId
if (!steamId) {
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
}
@ -19,6 +18,8 @@ export async function GET(
steamId: true,
name: true,
avatar: true,
status: true, // ✅ WICHTIG
lastActiveAt: true, // (optional) falls vorhanden
},
})
@ -26,12 +27,11 @@ export async function GET(
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 })
}
// Beispielhaft auch "stats" zurückgeben, falls gewünscht
const stats = await prisma.matchPlayer.findMany({
where: { steamId },
})
// (optional) wenn du weiterhin stats mitsenden willst:
// const stats = await prisma.matchPlayer.findMany({ where: { steamId } })
return NextResponse.json({ user, stats }, { status: 200 })
return NextResponse.json({ user }, { status: 200 })
// oder: return NextResponse.json({ user, stats }, { status: 200 })
} catch (error) {
console.error('[API] Fehler beim Laden des Users:', error)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })

View File

@ -0,0 +1,32 @@
// /src/app/api/user/activity/route.ts
import { NextResponse, NextRequest } 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) {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId // <-- hier definieren
if (!steamId) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
await prisma.user.update({
where: { steamId },
data: { status: 'online', lastActiveAt: new Date() },
})
// ➜ Broadcast an alle verbundenen Clients
await sendServerSSEMessage({
type: 'user-status-updated',
payload: {
steamId,
status: 'online',
updatedAt: new Date().toISOString(),
},
})
return NextResponse.json({ ok: true })
}

View File

@ -0,0 +1,32 @@
// /src/app/api/user/away/route.ts
import { NextResponse, NextRequest } 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) {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
await prisma.user.update({
where: { steamId },
data: { status: 'away' },
})
// ➜ Broadcast an alle verbundenen Clients (wie bei activity)
await sendServerSSEMessage({
type: 'user-status-updated',
payload: {
steamId,
status: 'away',
updatedAt: new Date().toISOString(),
},
})
return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } })
}

View File

@ -0,0 +1,30 @@
// /src/app/api/user/offline/route.ts
import { NextResponse, NextRequest } 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) {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!steamId) return NextResponse.json({ ok: true }, { status: 200 }) // still return 200 for beacon
await prisma.user.update({
where: { steamId },
data: { status: 'offline', lastActiveAt: new Date() },
})
// ➜ Broadcast an alle verbundenen Clients
await sendServerSSEMessage({
type: 'user-status-updated',
payload: {
steamId,
status: 'offline',
updatedAt: new Date().toISOString(),
},
})
return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } })
}

View File

@ -1,33 +1,95 @@
// src/app/api/user/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req))
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
if (!steamId) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
const user = await prisma.user.findUnique({
// 1) User + Team (nur skalare Felder + Leader-Relation laden)
const userRaw = await prisma.user.findUnique({
where: { steamId },
select: {
name: true,
steamId: true,
avatar: true,
team: true,
premierRank: true,
isAdmin: true,
status: true,
team: {
select: {
id: true,
name: true,
logo: true,
leaderId: true,
leader: {
select: {
steamId: true,
name: true,
avatar: true,
},
},
activePlayers: true,
inactivePlayers: true,
},
},
},
})
if (!user) {
if (!userRaw) {
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 })
}
return NextResponse.json(user)
// 2) Falls Team vorhanden: active/inactive IDs in User-Objekte auflösen
let teamResolved: any = null
if (userRaw.team) {
const activeIds = userRaw.team.activePlayers ?? []
const inactiveIds = userRaw.team.inactivePlayers ?? []
// findMany mit leeren Arrays ist ok und liefert []
const [activeUsers, inactiveUsers] = await Promise.all([
prisma.user.findMany({
where: { steamId: { in: activeIds } },
select: { steamId: true, name: true, avatar: true, premierRank: true },
}),
prisma.user.findMany({
where: { steamId: { in: inactiveIds } },
select: { steamId: true, name: true, avatar: true, premierRank: true },
}),
])
// Optional: Reihenfolge gemäß IDs beibehalten
const byId = (ids: string[], users: any[]) => {
const map = new Map(users.map((u) => [u.steamId, u]))
return ids.map((id) => map.get(id)).filter(Boolean)
}
teamResolved = {
id: userRaw.team.id,
name: userRaw.team.name,
logo: userRaw.team.logo,
leader: userRaw.team.leader ?? null,
activePlayers: byId(activeIds, activeUsers),
inactivePlayers: byId(inactiveIds, inactiveUsers),
}
}
// 3) Antwort formen (Team ersetzt durch aufgelöste Struktur)
const response = {
name: userRaw.name,
steamId: userRaw.steamId,
avatar: userRaw.avatar,
premierRank: userRaw.premierRank,
isAdmin: userRaw.isAdmin,
status: userRaw.status ?? 'offline',
team: teamResolved,
}
return NextResponse.json(response, { headers: { 'Cache-Control': 'no-store' } })
}

View File

@ -0,0 +1,34 @@
'use client'
import { useEffect } from 'react'
import { sound } from '@/app/lib/soundManager'
export default function AudioPrimer() {
useEffect(() => {
// Beim ersten User-Gesture: AudioContext entsperren & Sounds vorladen
const onFirstGesture = async () => {
await sound.prime();
if (sound.isPrimed()) {
await Promise.all([
sound.load('ready', '/assets/sounds/cs2_game_ready.wav'),
sound.load('beep', '/assets/sounds/beep.wav'),
sound.load('accept', '/assets/sounds/accept.wav'),
]);
}
window.removeEventListener('pointerdown', onFirstGesture);
window.removeEventListener('keydown', onFirstGesture);
window.removeEventListener('touchstart', onFirstGesture);
};
window.addEventListener('pointerdown', onFirstGesture, { once: true });
window.addEventListener('keydown', onFirstGesture, { once: true });
window.addEventListener('touchstart', onFirstGesture, { once: true });
return () => {
window.removeEventListener('pointerdown', onFirstGesture);
window.removeEventListener('keydown', onFirstGesture);
window.removeEventListener('touchstart', onFirstGesture);
};
}, []);
return null;
}

View File

@ -14,6 +14,7 @@ type ButtonProps = {
className?: string
dropDirection?: 'up' | 'down' | 'auto'
disabled?: boolean
loading?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
@ -29,6 +30,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
className,
dropDirection = 'down',
disabled = false,
loading = false,
...rest
},
ref
@ -115,14 +117,14 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
const variantClasses = variants[variant]?.[color] || variants.solid.blue
// Entfernt alle Hover/Focus/Active Tokens (inkl. dark:hover:..., sm:focus:..., etc.)
const stripInteractive = (cls: string) =>
cls
.split(/\s+/)
.filter(c => c && !c.includes('hover:') && !c.includes('focus:') && !c.includes('active:'))
.join(' ')
const safeVariantClasses = disabled ? stripInteractive(variantClasses) : variantClasses
const safeVariantClasses =
disabled || loading ? stripInteractive(variantClasses) : variantClasses
const classes = `
${base}
@ -160,11 +162,18 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
type="button"
className={classes}
onClick={toggle}
disabled={disabled}
disabled={disabled || loading}
{...modalAttributes}
{...rest}
>
{children ?? title}
{loading && (
<span
className="animate-spin inline-block size-4 border-[3px] border-current border-t-transparent rounded-full"
role="status"
aria-label="loading"
></span>
)}
{loading ? 'Lädt' : (children ?? title)}
</button>
)
})

View File

@ -1,12 +1,16 @@
'use client'
type ComboItem = { id: string; label: string }
type ComboBoxProps = {
value: string
items?: string[]
onSelect: (value: string) => void
value: string // ausgewählte ID
items: ComboItem[] // { id, label }
onSelect: (id: string) => void
}
export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
const selected = items.find(i => i.id === value)
return (
<div id="hs-combobox-basic-usage" className="relative" data-hs-combo-box="">
<div className="relative">
@ -15,7 +19,7 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
type="text"
role="combobox"
aria-expanded="false"
value={value}
value={selected?.label ?? ''}
data-hs-combo-box-input=""
readOnly
/>
@ -33,8 +37,8 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
stroke="currentColor"
strokeWidth="2"
>
<path d="m7 15 5 5 5-5"></path>
<path d="m7 9 5-5 5 5"></path>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</div>
</div>
@ -45,27 +49,36 @@ export default function ComboBox({ value, items, onSelect }: ComboBoxProps) {
role="listbox"
data-hs-combo-box-output=""
>
{items?.map((item, idx) => (
{items.map((item) => (
<div
key={idx}
className="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg dark:text-neutral-200 dark:hover:bg-neutral-800"
key={item.id}
className="cursor-pointer py-2 px-4 w-full text-sm text-gray-800 hover:bg-gray-100 rounded-lg focus:outline-hidden focus:bg-gray-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:text-neutral-200 dark:focus:bg-neutral-800"
role="option"
tabIndex={0}
onClick={() => onSelect(item)}
data-hs-combo-box-output-item=""
data-hs-combo-box-item-stored-data={JSON.stringify({ id: idx, name: item })}
data-hs-combo-box-item-stored-data={JSON.stringify({ id: item.id, name: item.label })}
onClick={() => onSelect(item.id)}
>
<div className="flex justify-between items-center w-full">
<span data-hs-combo-box-search-text={item} data-hs-combo-box-value="">{item}</span>
{item === value && (
<span className="hs-combo-box-selected:block">
<span
data-hs-combo-box-search-text={item.label}
data-hs-combo-box-value=""
>
{item.label}
</span>
{item.id === value && (
<span className="hidden hs-combo-box-selected:block">
<svg
className="shrink-0 size-3.5 text-blue-600 dark:text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>

View File

@ -130,14 +130,14 @@ export default function CommunityMatchList({ matchType }: Props) {
;(async () => {
setLoadingTeams(true)
try {
const res = await fetch('/api/team/list', { cache: 'no-store' })
const res = await fetch('/api/teams', { cache: 'no-store' })
const json = await res.json()
const opts: TeamOption[] = (json.teams ?? []).map((t: any) => ({
id: t.id, name: t.name, logo: t.logo,
}))
setTeams(opts)
} catch (e) {
console.error('[MatchList] /api/team/list fehlgeschlagen:', e)
console.error('[MatchList] /api/teams fehlgeschlagen:', e)
setTeams([])
} finally {
setLoadingTeams(false)

View File

@ -1,163 +1,164 @@
'use client'
import { useEffect, useState, useImperativeHandle, forwardRef } from 'react'
import { useState, forwardRef } from 'react'
import { useSession } from 'next-auth/react'
import Modal from './Modal'
import Button from './Button'
type CreateTeamButtonProps = {
setRefetchKey: (key: string) => void
/** Optional: Parent kann damit eine Liste refreshen */
setRefetchKey?: (key: string) => void
}
const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ setRefetchKey }, ref) => {
const { data: session } = useSession()
const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(
({ setRefetchKey }, ref) => {
const { data: session } = useSession()
const [teamname, setTeamname] = useState('')
const [showModal, setShowModal] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [message, setMessage] = useState('')
const [teamname, setTeamname] = useState('')
const [showModal, setShowModal] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [message, setMessage] = useState('')
// ⬇️ Helfer: Modal schließen + Backdrop sicher entfernen
const closeCreateModalAndCleanup = () => {
const modalEl = document.getElementById('modal-create-team')
// Modal schließen + Backdrop sicher entfernen
const closeCreateModalAndCleanup = () => {
const modalEl = document.getElementById('modal-create-team')
// 1) HSOverlay schließen (falls vorhanden)
if (modalEl && (window as any).HSOverlay?.close) {
;(window as any).HSOverlay.close(modalEl)
}
if (modalEl && (window as any).HSOverlay?.close) {
;(window as any).HSOverlay.close(modalEl)
}
// 2) React-State schließen (sorgt dafür, dass unser Modal unmounted)
setShowModal(false)
setShowModal(false)
// 3) HARTE Aufräumaktion: evtl. übrig gebliebene Backdrops/Overlays deaktivieren/entfernen
requestAnimationFrame(() => {
// alle Backdrops entfernen
document.querySelectorAll('.hs-overlay-backdrop').forEach(el => el.remove())
// sicherheitshalber versteckte Overlays un-klickbar machen
document.querySelectorAll('.hs-overlay[aria-hidden="true"]').forEach(el => {
(el as HTMLElement).style.pointerEvents = 'none'
;(el as HTMLElement).style.display = 'none'
requestAnimationFrame(() => {
document.querySelectorAll('.hs-overlay-backdrop').forEach(el => el.remove())
document.querySelectorAll('.hs-overlay[aria-hidden="true"]').forEach(el => {
(el as HTMLElement).style.pointerEvents = 'none'
;(el as HTMLElement).style.display = 'none'
})
document.body.classList.remove('overflow-hidden')
})
// falls HSOverlay Klassen am Body gesetzt hat
document.body.classList.remove('overflow-hidden')
})
}
const handleSubmit = async () => {
setStatus('idle')
setMessage('')
if (!teamname.trim()) {
setStatus('error')
setMessage('Bitte gib einen Teamnamen ein.')
return
}
try {
const res = await fetch('/api/team/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamname, leader: session?.user?.steamId }),
})
const handleSubmit = async () => {
setStatus('idle')
setMessage('')
const result = await res.json()
if (!res.ok) throw new Error(result.message || 'Fehler beim Erstellen')
if (!teamname.trim()) {
setStatus('error')
setMessage('Bitte gib einen Teamnamen ein.')
return
}
setStatus('success')
setMessage(`Team "${result.team.name}" wurde erfolgreich erstellt!`)
setTeamname('')
try {
const res = await fetch('/api/team/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamname, leader: session?.user?.steamId }),
})
// 🔒 Nach kurzer Bestätigung Modal schließen, Backdrop bereinigen und Liste refreshen
setTimeout(() => {
closeCreateModalAndCleanup()
// einen Tick warten, bis DOM frei ist
requestAnimationFrame(() => setRefetchKey(Date.now().toString()))
}, 800) // kürzer reicht idR, 800ms für Feedback
} catch (err: any) {
setStatus('error')
setMessage(err.message || 'Fehler beim Erstellen des Teams')
}
}
const result = await res.json()
if (!res.ok) throw new Error(result.message || 'Fehler beim Erstellen')
return (
<div>
<Button onClick={() => setShowModal(true)} color="blue" variant="solid" size="sm">
Neues Team erstellen
</Button>
setStatus('success')
setMessage(`Team "${result.team.name}" wurde erfolgreich erstellt!`)
setTeamname('')
<Modal
id="modal-create-team"
title="Neues Team erstellen"
show={showModal}
onClose={() => {
setShowModal(false)
// Falls Benutzer per X schließt auch dann cleanen
// Nach kurzer Bestätigung Modal schließen, aufräumen und optional Parent refreshen
setTimeout(() => {
closeCreateModalAndCleanup()
}}
onSave={handleSubmit}
closeButtonTitle="Team erstellen"
>
<div className="max-w-sm space-y-2">
<label htmlFor="teamname" className="block text-sm font-medium mb-2 dark:text-white">
Teamname
</label>
requestAnimationFrame(() => {
// Nur aufrufen, wenn vom Parent übergeben
setRefetchKey?.(Date.now().toString())
})
}, 800)
} catch (err: any) {
setStatus('error')
setMessage(err.message || 'Fehler beim Erstellen des Teams')
}
}
<input
id="teamname"
type="text"
value={teamname}
onChange={(e) => {
setTeamname(e.target.value)
setStatus('idle')
setMessage('')
}}
placeholder="Gebe einen Teamnamen ein..."
className={`
py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600
${status === 'error'
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: status === 'success'
? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'}
`}
required
name="teamname"
aria-describedby="teamname-feedback"
/>
return (
<div ref={ref}>
<Button onClick={() => setShowModal(true)} color="blue" variant="solid" size="sm">
Neues Team erstellen
</Button>
{status !== 'idle' && (
<div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none">
<svg
className={`shrink-0 size-4 ${status === 'error' ? 'text-red-500' : 'text-teal-500'}`}
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
{status === 'error' ? (
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</>
) : (
<polyline points="20 6 9 17 4 12" />
)}
</svg>
</div>
)}
<Modal
id="modal-create-team"
title="Neues Team erstellen"
show={showModal}
onClose={() => {
setShowModal(false)
closeCreateModalAndCleanup()
}}
onSave={handleSubmit}
closeButtonTitle="Team erstellen"
>
<div className="max-w-sm space-y-2 relative">
<label htmlFor="teamname" className="block text-sm font-medium mb-2 dark:text-white">
Teamname
</label>
{message && (
<p
id="teamname-feedback"
className={`text-sm mt-1 ${status === 'error' ? 'text-red-600' : 'text-teal-600'}`}
>
{message}
</p>
)}
</div>
</Modal>
</div>
)
})
<input
id="teamname"
type="text"
value={teamname}
onChange={(e) => {
setTeamname(e.target.value)
setStatus('idle')
setMessage('')
}}
placeholder="Gebe einen Teamnamen ein..."
className={`
py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600
${
status === 'error'
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: status === 'success'
? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}
`}
required
name="teamname"
aria-describedby="teamname-feedback"
/>
{status !== 'idle' && (
<div className="absolute right-3 top-10 flex items-center pointer-events-none">
<svg
className={`size-4 ${status === 'error' ? 'text-red-500' : 'text-teal-500'}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
{status === 'error' ? (
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</>
) : (
<polyline points="20 6 9 17 4 12" />
)}
</svg>
</div>
)}
{message && (
<p id="teamname-feedback" className={`text-sm mt-1 ${status === 'error' ? 'text-red-600' : 'text-teal-600'}`}>
{message}
</p>
)}
</div>
</Modal>
</div>
)
}
)
CreateTeamButton.displayName = 'CreateTeamButton'
export default CreateTeamButton

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import Modal from './Modal'
import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react'
@ -30,10 +30,20 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const [isSuccess, setIsSuccess] = useState(false)
const [sentCount, setSentCount] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
const usersPerPage = 9
// Dynamisch berechnete Items pro Seite
const [usersPerPage, setUsersPerPage] = useState<number>(9)
const [currentPage, setCurrentPage] = useState(1)
const [invitedStatus, setInvitedStatus] = useState<Record<string, InviteStatus>>({})
// Refs für die Messung des verfügbaren Platzes
const descRef = useRef<HTMLParagraphElement>(null)
const selectedWrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLDivElement>(null)
const successRef = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
const firstCardRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (show) {
fetchUsersNotInTeam()
@ -43,6 +53,22 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
}
}, [show])
useEffect(() => {
if (!gridRef.current) return;
const observer = new ResizeObserver(() => {
if (!gridRef.current) return;
const gridHeight = gridRef.current.clientHeight;
const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0;
const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1;
const cols = window.innerWidth >= 640 ? 3 : 2; // sm:grid-cols-3 vs grid-cols-2
setUsersPerPage(rows * cols);
});
observer.observe(gridRef.current);
return () => observer.disconnect();
}, []);
const fetchUsersNotInTeam = async () => {
try {
setIsLoading(true)
@ -55,10 +81,8 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
}
const handleSelect = (steamId: string) => {
setSelectedIds((prev) =>
prev.includes(steamId)
? prev.filter((id) => id !== steamId)
: [...prev, steamId]
setSelectedIds(prev =>
prev.includes(steamId) ? prev.filter(id => id !== steamId) : [...prev, steamId]
)
}
@ -78,19 +102,14 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
body: JSON.stringify(body),
})
// Versuch: partielle Resultate lesen
let detail: any = null
try { detail = await res.clone().json() } catch {}
if (res.ok) {
const okStatus: InviteStatus = directAdd ? 'added' : 'sent'
setInvitedStatus(prev => ({
...prev,
...Object.fromEntries(ids.map(id => [id, okStatus]))
}))
setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(ids.map(id => [id, okStatus])) }))
setSentCount(ids.length)
} else if (detail?.results && Array.isArray(detail.results)) {
// erwartetes Schema: [{ steamId, ok }]
let okCount = 0
const next: Record<string, InviteStatus> = {}
for (const r of detail.results) {
@ -101,11 +120,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
setInvitedStatus(prev => ({ ...prev, ...next }))
setSentCount(okCount)
} else {
// alles fehlgeschlagen
setInvitedStatus(prev => ({
...prev,
...Object.fromEntries(ids.map(id => [id, 'failed' as InviteStatus]))
}))
setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(ids.map(id => [id, 'failed'])) }))
setSentCount(0)
}
@ -115,10 +130,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
onSuccess()
} catch (err) {
console.error('Fehler beim Einladen:', err)
setInvitedStatus(prev => ({
...prev,
...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus]))
}))
setInvitedStatus(prev => ({ ...prev, ...Object.fromEntries(selectedIds.map(id => [id, 'failed'])) }))
setInvitedIds(selectedIds)
setSentCount(0)
setIsSuccess(true)
@ -129,38 +141,130 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
if (isSuccess) {
const timeout = setTimeout(() => {
const modalEl = document.getElementById('invite-members-modal')
if (modalEl && window.HSOverlay?.close) {
window.HSOverlay.close(modalEl)
if (modalEl && (window as any).HSOverlay?.close) {
(window as any).HSOverlay.close(modalEl)
}
onClose()
}, 2000)
return () => clearTimeout(timeout)
}
}, [isSuccess, onClose])
useEffect(() => {
setCurrentPage(1)
}, [selectedIds, searchTerm])
}, [searchTerm])
const filteredUsers = allUsers.filter(user =>
user.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
// Sortierung: ausgewählte nach vorne (nur für Anzeige der Selected-List)
const sortedUsers = [...filteredUsers].sort((a, b) => {
const aSelected = selectedIds.includes(a.steamId) ? -1 : 0
const bSelected = selectedIds.includes(b.steamId) ? -1 : 0
return aSelected - bSelected
})
const unselectedUsers = filteredUsers.filter((user) =>
const unselectedUsers = filteredUsers.filter(user =>
!selectedIds.includes(user.steamId) &&
(!isSuccess || !invitedIds.includes(user.steamId))
)
const totalPages = Math.ceil(unselectedUsers.length / usersPerPage)
const startIdx = (currentPage - 1) * usersPerPage
const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + usersPerPage)
const totalPages = Math.ceil(unselectedUsers.length / Math.max(1, usersPerPage))
const startIdx = (currentPage - 1) * Math.max(1, usersPerPage)
const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + Math.max(1, usersPerPage))
// Seite einklemmen, wenn PerPage / Anzahl sich ändern
useEffect(() => {
if (totalPages === 0 && currentPage !== 1) setCurrentPage(1)
else if (currentPage > totalPages) setCurrentPage(totalPages || 1)
}, [totalPages, currentPage])
// ---- Dynamische Berechnung von usersPerPage (keine Scrollbars im Modal) ----
const recalcUsersPerPage = useCallback(() => {
const gridEl = gridRef.current
if (!gridEl) return
// Modal-Body ist der Parent des Grids
const bodyEl = gridEl.parentElement as HTMLElement | null
if (!bodyEl) return
// bereits genutzte Höhe "oberhalb" des Grids
const outer = (el: HTMLElement | null) => {
if (!el) return 0
const cs = getComputedStyle(el)
return el.offsetHeight + parseFloat(cs.marginTop || '0') + parseFloat(cs.marginBottom || '0')
}
// Abstand des Grids selbst (z.B. mt-4)
const gridMT = parseFloat(getComputedStyle(gridEl).marginTop || '0')
const usedAbove =
outer(descRef.current) +
outer(selectedWrapRef.current) +
outer(searchRef.current) +
outer(successRef.current) +
gridMT
// Optional etwas Platz für Pagination reservieren (wenn sie sichtbar wäre)
const reserveForPagination = (!isLoading && !isSuccess && totalPages > 1) ? 48 : 0
const availableHeight = Math.max(0, bodyEl.clientHeight - usedAbove - reserveForPagination)
// Ermittel Spalten und Zeilenabstand aus dem Grid
const csGrid = getComputedStyle(gridEl)
const cols = Math.max(
1,
csGrid.gridTemplateColumns.split(' ').filter(Boolean).length
)
const rowGap = parseFloat(csGrid.rowGap || '0')
// Kartenhöhe messen: nimm die erste echte Karte, sonst Fallback
const cardEl = firstCardRef.current
let cardHeight = cardEl?.offsetHeight || 0
if (!cardHeight) {
// vorsichtiger Fallback (angepasst an typische MiniCard)
cardHeight = 140
}
// rows * cardHeight + (rows - 1) * rowGap <= availableHeight
// => rows <= (availableHeight + rowGap) / (cardHeight + rowGap)
const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (cardHeight + rowGap)))
const nextPerPage = Math.max(1, rows * cols)
if (nextPerPage !== usersPerPage) {
setUsersPerPage(nextPerPage)
}
}, [isLoading, isSuccess, totalPages, usersPerPage])
// Recalc auf Resize/Content-Änderungen
useEffect(() => {
// Direkt versuchen
recalcUsersPerPage()
const gridEl = gridRef.current
if (!gridEl) return
const bodyEl = gridEl.parentElement as HTMLElement | null
const cardEl = firstCardRef.current
const ro = new ResizeObserver(() => recalcUsersPerPage())
ro.observe(gridEl)
if (bodyEl) ro.observe(bodyEl)
if (cardEl) ro.observe(cardEl)
const onResize = () => recalcUsersPerPage()
window.addEventListener('resize', onResize)
// bei Show/Hide leichter Delay, bis Layout stabil ist
const id = window.setTimeout(recalcUsersPerPage, 60)
return () => {
window.clearTimeout(id)
ro.disconnect()
window.removeEventListener('resize', onResize)
}
}, [recalcUsersPerPage, show, filteredUsers.length, isSuccess])
return (
<Modal
@ -175,8 +279,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
? directAdd ? 'Spieler hinzugefügt' : 'Einladungen versendet'
: directAdd ? 'Hinzufügen' : 'Einladungen senden'
}
scrollBody={true}
>
<p className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
<p ref={descRef} className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
{directAdd
? 'Wähle Spieler aus, die du direkt zum Team hinzufügen möchtest:'
: 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}
@ -184,7 +289,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
{/* Ausgewählte Benutzer anzeigen */}
{selectedIds.length > 0 && (
<div className="col-span-full">
<div ref={selectedWrapRef} className="col-span-full">
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">
Ausgewählte Spieler:
</h3>
@ -211,7 +316,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
hideActions={true}
rank={user.premierRank}
/>
@ -223,23 +328,29 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
</div>
)}
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mt-2 w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring focus:ring-blue-400 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
/>
<div ref={searchRef}>
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mt-2 w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring focus:ring-blue-400 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
/>
</div>
{isSuccess && (
<div className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg">
<div
ref={successRef}
className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg"
>
{directAdd
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`}
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{/* Grid der MiniCards — Höhe/Anzahl wird dynamisch berechnet */}
<div ref={gridRef} className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{isLoading ? (
<LoadingSpinner />
) : filteredUsers.length === 0 ? (
@ -252,7 +363,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
</div>
) : (
<AnimatePresence mode="popLayout" initial={false}>
{!isSuccess && paginatedUsers.map((user) => (
{!isSuccess && paginatedUsers.map((user, idx) => (
<motion.div
key={user.steamId}
layout
@ -260,6 +371,8 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
// 👉 erste sichtbare Karte als Mess-Referenz verwenden
ref={idx === 0 ? firstCardRef : undefined}
>
<MiniCard
steamId={user.steamId}
@ -270,14 +383,14 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
hideActions
rank={user.premierRank}
/>
</motion.div>
))}
{isSuccess && invitedIds.map((id) => {
{isSuccess && invitedIds.map((id, idx) => {
const user = allUsers.find((u) => u.steamId === id)
if (!user) return null
return (
@ -288,6 +401,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
ref={idx === 0 ? firstCardRef : undefined}
>
<MiniCard
steamId={user.steamId}
@ -297,7 +411,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
selected={false}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
teamLeaderSteamId={(team as any).leader?.steamId ?? (team as any).leader}
hideActions
rank={user.premierRank}
invitedStatus={invitedStatus[user.steamId]}
@ -308,6 +422,22 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
</AnimatePresence>
)}
</div>
{/* Pagination */}
{!isLoading && !isSuccess && totalPages > 1 && (
<div className="mt-3 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={(p) => {
const next = Math.max(1, Math.min(totalPages, p))
setCurrentPage(next)
// nach Page-Wechsel neu kalkulieren (Layout kann „springen“)
setTimeout(() => recalcUsersPerPage(), 0)
}}
/>
</div>
)}
</Modal>
)
}

View File

@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore'
import type { MapVoteState } from '../types/mapvote'
import { MATCH_EVENTS } from '@/app/lib/sseEvents'
type Props = { match: any; initialNow: number }
@ -46,8 +47,13 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
// Live-Refresh via SSE
useEffect(() => {
if (!lastEvent || lastEvent.type !== 'map-vote-updated') return
if (lastEvent.payload?.matchId !== match.id) return
if (!lastEvent) return
// ⬇️ reagiert auf alle match-bezogenen Events: vote-updated, admin-edit, reset, ready, lineup, ...
if (!MATCH_EVENTS.has(lastEvent.type)) return
const matchId = lastEvent.payload?.matchId
if (matchId !== match.id) return
load()
}, [lastEvent, match.id, load])
@ -85,7 +91,7 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
'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-green-500/15 hover:ring-green-500/25 hover:shadow-md'
: 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md')
return (
@ -101,7 +107,7 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
<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">
<div className="shrink-0 w-9 h-9 rounded-full grid place-items-center bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-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>
@ -133,7 +139,7 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
Voting 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">
<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">
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
</span>
) : (
@ -152,9 +158,9 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
.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%
rgba(16, 168, 54, 0.20) 0%,
rgba(16, 168, 54, 0.04) 50%,
rgba(16, 168, 54, 0.20) 100%
);
background-size: 200% 100%;
background-repeat: repeat-x;
@ -163,9 +169,9 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
: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%
rgba(16, 168, 54, 0.28) 0%,
rgba(16, 168, 54, 0.08) 50%,
rgba(16, 168, 54, 0.28) 100%
);
}
@media (prefers-reduced-motion: reduce) {

Some files were not shown because too many files have changed in this diff Show More