updated
7
.env
@ -16,3 +16,10 @@ 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
|
||||
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
|
||||
@ -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
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
BIN
public/assets/img/maps/de_ancient/1.webp
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
public/assets/img/maps/de_ancient/2.webp
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
public/assets/img/maps/de_ancient/3.webp
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
public/assets/img/maps/de_ancient/4.webp
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
public/assets/img/maps/de_ancient/5.webp
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
public/assets/img/maps/de_ancient/6.webp
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
public/assets/img/maps/de_anubis/1.webp
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
public/assets/img/maps/de_anubis/2.webp
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
public/assets/img/maps/de_anubis/3.webp
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
public/assets/img/maps/de_anubis/4.webp
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
public/assets/img/maps/de_anubis/5.webp
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
public/assets/img/maps/de_anubis/6.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
public/assets/img/maps/de_dust2/1.webp
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
public/assets/img/maps/de_dust2/2.webp
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
public/assets/img/maps/de_dust2/3.webp
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
public/assets/img/maps/de_dust2/4.webp
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
public/assets/img/maps/de_grail/1.webp
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
public/assets/img/maps/de_grail/2.webp
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
public/assets/img/maps/de_grail/3.webp
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
public/assets/img/maps/de_grail/4.webp
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
public/assets/img/maps/de_grail/5.webp
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
public/assets/img/maps/de_grail/6.webp
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/assets/img/maps/de_inferno/1.webp
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
public/assets/img/maps/de_inferno/2.webp
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
public/assets/img/maps/de_inferno/3.webp
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
public/assets/img/maps/de_inferno/4.webp
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
public/assets/img/maps/de_inferno/5.webp
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
public/assets/img/maps/de_inferno/6.webp
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
public/assets/img/maps/de_jura/1.webp
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
public/assets/img/maps/de_jura/2.webp
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
public/assets/img/maps/de_jura/3.webp
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
public/assets/img/maps/de_jura/4.webp
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
public/assets/img/maps/de_jura/5.webp
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
public/assets/img/maps/de_jura/6.webp
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/assets/img/maps/de_jura/7.webp
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
public/assets/img/maps/de_jura/8.webp
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
public/assets/img/maps/de_mirage/1.webp
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
public/assets/img/maps/de_mirage/2.webp
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
public/assets/img/maps/de_mirage/3.webp
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
public/assets/img/maps/de_mirage/4.webp
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
public/assets/img/maps/de_mirage/5.webp
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
public/assets/img/maps/de_mirage/6.webp
Normal file
|
After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 34 KiB |
BIN
public/assets/img/maps/de_nuke/1.webp
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
public/assets/img/maps/de_nuke/2.webp
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/assets/img/maps/de_nuke/3.webp
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
public/assets/img/maps/de_nuke/4.webp
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
public/assets/img/maps/de_nuke/5.webp
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/assets/img/maps/de_nuke/6.webp
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
public/assets/img/maps/de_overpass/1.webp
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/assets/img/maps/de_overpass/2.webp
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
public/assets/img/maps/de_overpass/3.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/assets/img/maps/de_overpass/4.webp
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
public/assets/img/maps/de_overpass/5.webp
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
public/assets/img/maps/de_overpass/6.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/assets/img/maps/de_overpass/7.webp
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
public/assets/img/maps/de_overpass/8.webp
Normal file
|
After Width: | Height: | Size: 300 KiB |
BIN
public/assets/img/maps/de_train/1.webp
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
public/assets/img/maps/de_train/2.webp
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
public/assets/img/maps/de_train/3.webp
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
public/assets/img/maps/de_train/4.webp
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/assets/img/maps/de_train/5.webp
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/assets/img/maps/de_train/6.webp
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
public/assets/img/maps/de_vertigo/1.webp
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
public/assets/img/maps/de_vertigo/2.webp
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
public/assets/img/maps/de_vertigo/3.webp
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
public/assets/img/maps/de_vertigo/4.webp
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
public/assets/img/maps/de_vertigo/5.webp
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/assets/img/maps/de_vertigo/6.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
public/assets/sounds/accept.wav
Normal file
BIN
public/assets/sounds/beep.wav
Normal file
BIN
public/assets/sounds/cs2_game_ready.wav
Normal file
BIN
public/assets/vids/ffmpeg.exe
Normal file
BIN
public/assets/vids/overlay_cs2_accept.webm
Normal file
BIN
public/assets/vids/overlay_cs2_accept.webp
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
151
src/app/api/matches/[matchId]/export-to-sftp/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
39
src/app/api/ptero/send-command/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 })
|
||||
|
||||
32
src/app/api/user/activity/route.ts
Normal 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 })
|
||||
}
|
||||
32
src/app/api/user/away/route.ts
Normal 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' } })
|
||||
}
|
||||
30
src/app/api/user/offline/route.ts
Normal 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' } })
|
||||
}
|
||||
@ -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' } })
|
||||
}
|
||||
|
||||
34
src/app/components/AudioPrimer.tsx
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||