updated
This commit is contained in:
parent
e693af798b
commit
237be94ebe
2
.env
2
.env
@ -14,7 +14,7 @@ AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`
|
|||||||
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
|
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
|
||||||
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
|
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
|
||||||
PTERODACTYL_CLIENT_API=ptlc_6NXqjxieIekaULga2jmuTPyPwdziigT82PRbrg3G4S7
|
PTERODACTYL_CLIENT_API=ptlc_6NXqjxieIekaULga2jmuTPyPwdziigT82PRbrg3G4S7
|
||||||
PTERO_PANEL_URL=https://panel.ironieopen.de
|
PTERODACTYL_PANEL_URL=https://panel.ironieopen.de
|
||||||
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
|
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
|
||||||
PTERO_SERVER_SFTP_USER=army.37a11489
|
PTERO_SERVER_SFTP_USER=army.37a11489
|
||||||
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
||||||
|
|||||||
72
package-lock.json
generated
72
package-lock.json
generated
@ -15,7 +15,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||||
"@preline/dropdown": "^3.0.1",
|
"@preline/dropdown": "^3.0.1",
|
||||||
"@preline/tooltip": "^3.0.0",
|
"@preline/tooltip": "^3.0.0",
|
||||||
"@prisma/client": "^6.15.0",
|
"@prisma/client": "^6.16.1",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csgo-sharecode": "^3.1.2",
|
"csgo-sharecode": "^3.1.2",
|
||||||
@ -61,7 +61,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.0",
|
"eslint-config-next": "15.3.0",
|
||||||
"prisma": "^6.15.0",
|
"prisma": "^6.16.1",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
@ -1599,9 +1599,9 @@
|
|||||||
"license": "Licensed under MIT and Preline UI Fair Use License"
|
"license": "Licensed under MIT and Preline UI Fair Use License"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.15.0",
|
"version": "6.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.1.tgz",
|
||||||
"integrity": "sha512-wR2LXUbOH4cL/WToatI/Y2c7uzni76oNFND7+23ypLllBmIS8e3ZHhO+nud9iXSXKFt1SoM3fTZvHawg63emZw==",
|
"integrity": "sha512-QaBCOY29lLAxEFFJgBPyW3WInCW52fJeQTmWx/h6YsP5u0bwuqP51aP0uhqFvhK9DaZPwvai/M4tSDYLVE9vRg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1621,9 +1621,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/config": {
|
"node_modules/@prisma/config": {
|
||||||
"version": "6.15.0",
|
"version": "6.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.1.tgz",
|
||||||
"integrity": "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA==",
|
"integrity": "sha512-sz3uxRPNL62QrJ0EYiujCFkIGZ3hg+9hgC1Ae1HjoYuj0BxCqHua4JNijYvYCrh9LlofZDZcRBX3tHBfLvAngA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1634,53 +1634,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/debug": {
|
"node_modules/@prisma/debug": {
|
||||||
"version": "6.15.0",
|
"version": "6.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.1.tgz",
|
||||||
"integrity": "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g==",
|
"integrity": "sha512-RWv/VisW5vJE4cDRTuAHeVedtGoItXTnhuLHsSlJ9202QKz60uiXWywBlVcqXVq8bFeIZoCoWH+R1duZJPwqLw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "6.15.0",
|
"version": "6.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.1.tgz",
|
||||||
"integrity": "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA==",
|
"integrity": "sha512-EOnEM5HlosPudBqbI+jipmaW/vQEaF0bKBo4gVkGabasINHR6RpC6h44fKZEqx4GD8CvH+einD2+b49DQrwrAg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.15.0",
|
"@prisma/debug": "6.16.1",
|
||||||
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||||
"@prisma/fetch-engine": "6.15.0",
|
"@prisma/fetch-engine": "6.16.1",
|
||||||
"@prisma/get-platform": "6.15.0"
|
"@prisma/get-platform": "6.16.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines-version": {
|
"node_modules/@prisma/engines-version": {
|
||||||
"version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
|
||||||
"integrity": "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg==",
|
"integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "6.15.0",
|
"version": "6.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.1.tgz",
|
||||||
"integrity": "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw==",
|
"integrity": "sha512-fl/PKQ8da5YTayw86WD3O9OmKJEM43gD3vANy2hS5S1CnfW2oPXk+Q03+gUWqcKK306QqhjjIHRFuTZ31WaosQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.15.0",
|
"@prisma/debug": "6.16.1",
|
||||||
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||||
"@prisma/get-platform": "6.15.0"
|
"@prisma/get-platform": "6.16.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/get-platform": {
|
"node_modules/@prisma/get-platform": {
|
||||||
"version": "6.15.0",
|
"version": "6.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.1.tgz",
|
||||||
"integrity": "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg==",
|
"integrity": "sha512-kUfg4vagBG7dnaGRcGd1c0ytQFcDj2SUABiuveIpL3bthFdTLI6PJeLEia6Q8Dgh+WhPdo0N2q0Fzjk63XTyaA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/debug": "6.15.0"
|
"@prisma/debug": "6.16.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
@ -6781,15 +6781,15 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.15.0",
|
"version": "6.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.1.tgz",
|
||||||
"integrity": "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ==",
|
"integrity": "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/config": "6.15.0",
|
"@prisma/config": "6.16.1",
|
||||||
"@prisma/engines": "6.15.0"
|
"@prisma/engines": "6.16.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"prisma": "build/index.js"
|
"prisma": "build/index.js"
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||||
"@preline/dropdown": "^3.0.1",
|
"@preline/dropdown": "^3.0.1",
|
||||||
"@preline/tooltip": "^3.0.0",
|
"@preline/tooltip": "^3.0.0",
|
||||||
"@prisma/client": "^6.15.0",
|
"@prisma/client": "^6.16.1",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csgo-sharecode": "^3.1.2",
|
"csgo-sharecode": "^3.1.2",
|
||||||
@ -65,7 +65,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.0",
|
"eslint-config-next": "15.3.0",
|
||||||
"prisma": "^6.15.0",
|
"prisma": "^6.16.1",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
|
|||||||
@ -1,372 +1,388 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../src/generated/prisma"
|
output = "../src/generated/prisma"
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// 🧑 Benutzer, Teams & Verwaltung
|
// 🧑 Benutzer, Teams & Verwaltung
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
steamId String @id
|
steamId String @id
|
||||||
name String?
|
name String?
|
||||||
avatar String?
|
avatar String?
|
||||||
location String?
|
location String?
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
|
|
||||||
teamId String?
|
teamId String?
|
||||||
team Team? @relation("UserTeam", fields: [teamId], references: [id])
|
team Team? @relation("UserTeam", fields: [teamId], references: [id])
|
||||||
ledTeam Team? @relation("TeamLeader")
|
ledTeam Team? @relation("TeamLeader")
|
||||||
|
|
||||||
matchesAsTeamA Match[] @relation("TeamAPlayers")
|
matchesAsTeamA Match[] @relation("TeamAPlayers")
|
||||||
matchesAsTeamB Match[] @relation("TeamBPlayers")
|
matchesAsTeamB Match[] @relation("TeamBPlayers")
|
||||||
|
|
||||||
premierRank Int?
|
premierRank Int?
|
||||||
authCode String?
|
authCode String?
|
||||||
lastKnownShareCode String?
|
lastKnownShareCode String?
|
||||||
lastKnownShareCodeDate DateTime?
|
lastKnownShareCodeDate DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
invites TeamInvite[] @relation("UserInvitations")
|
invites TeamInvite[] @relation("UserInvitations")
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
matchPlayers MatchPlayer[]
|
matchPlayers MatchPlayer[]
|
||||||
serverRequests ServerRequest[] @relation("MatchRequests")
|
serverRequests ServerRequest[] @relation("MatchRequests")
|
||||||
rankHistory RankHistory[] @relation("UserRankHistory")
|
rankHistory RankHistory[] @relation("UserRankHistory")
|
||||||
demoFiles DemoFile[]
|
demoFiles DemoFile[]
|
||||||
|
|
||||||
createdSchedules Schedule[] @relation("CreatedSchedules")
|
createdSchedules Schedule[] @relation("CreatedSchedules")
|
||||||
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
|
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
|
||||||
|
|
||||||
mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
|
mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
|
||||||
|
|
||||||
status UserStatus @default(offline) // 👈 neu
|
status UserStatus @default(offline) // 👈 neu
|
||||||
lastActiveAt DateTime? // optional: wann zuletzt aktiv
|
lastActiveAt DateTime? // optional: wann zuletzt aktiv
|
||||||
|
|
||||||
readyAcceptances MatchReady[] @relation("MatchReadyUser")
|
readyAcceptances MatchReady[] @relation("MatchReadyUser")
|
||||||
}
|
|
||||||
|
pterodactylClientApiKey String?
|
||||||
enum UserStatus {
|
}
|
||||||
online
|
|
||||||
away
|
enum UserStatus {
|
||||||
offline
|
online
|
||||||
}
|
away
|
||||||
|
offline
|
||||||
model Team {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
name String @unique
|
model Team {
|
||||||
logo String?
|
id String @id @default(uuid())
|
||||||
leaderId String? @unique
|
name String @unique
|
||||||
createdAt DateTime @default(now())
|
logo String?
|
||||||
|
leaderId String? @unique
|
||||||
activePlayers String[]
|
createdAt DateTime @default(now())
|
||||||
inactivePlayers String[]
|
|
||||||
|
activePlayers String[]
|
||||||
leader User? @relation("TeamLeader", fields: [leaderId], references: [steamId])
|
inactivePlayers String[]
|
||||||
members User[] @relation("UserTeam")
|
|
||||||
invites TeamInvite[]
|
leader User? @relation("TeamLeader", fields: [leaderId], references: [steamId])
|
||||||
matchPlayers MatchPlayer[]
|
members User[] @relation("UserTeam")
|
||||||
|
invites TeamInvite[]
|
||||||
matchesAsTeamA Match[] @relation("MatchTeamA")
|
matchPlayers MatchPlayer[]
|
||||||
matchesAsTeamB Match[] @relation("MatchTeamB")
|
|
||||||
|
matchesAsTeamA Match[] @relation("MatchTeamA")
|
||||||
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
|
matchesAsTeamB Match[] @relation("MatchTeamB")
|
||||||
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
|
|
||||||
|
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
|
||||||
mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
|
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
|
||||||
}
|
|
||||||
|
mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
|
||||||
model TeamInvite {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
steamId String
|
model TeamInvite {
|
||||||
teamId String
|
id String @id @default(uuid())
|
||||||
type String
|
steamId String
|
||||||
createdAt DateTime @default(now())
|
teamId String
|
||||||
|
type String
|
||||||
user User @relation("UserInvitations", fields: [steamId], references: [steamId])
|
createdAt DateTime @default(now())
|
||||||
team Team @relation(fields: [teamId], references: [id])
|
|
||||||
}
|
user User @relation("UserInvitations", fields: [steamId], references: [steamId])
|
||||||
|
team Team @relation(fields: [teamId], references: [id])
|
||||||
model Notification {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
steamId String
|
model Notification {
|
||||||
title String?
|
id String @id @default(uuid())
|
||||||
message String
|
steamId String
|
||||||
read Boolean @default(false)
|
title String?
|
||||||
persistent Boolean @default(false)
|
message String
|
||||||
actionType String?
|
read Boolean @default(false)
|
||||||
actionData String?
|
persistent Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
actionType String?
|
||||||
|
actionData String?
|
||||||
user User @relation(fields: [steamId], references: [steamId])
|
createdAt DateTime @default(now())
|
||||||
}
|
|
||||||
|
user User @relation(fields: [steamId], references: [steamId])
|
||||||
//
|
}
|
||||||
// ──────────────────────────────────────────────
|
|
||||||
// 🎮 Matches & Spieler
|
//
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
//
|
// 🎮 Matches & Spieler
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
// ──────────────────────────────────────────────
|
//
|
||||||
// 🎮 Matches
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
// 🎮 Matches
|
||||||
model Match {
|
// ──────────────────────────────────────────────
|
||||||
id String @id @default(uuid())
|
|
||||||
title String
|
model Match {
|
||||||
matchType String @default("community")
|
id String @id @default(uuid())
|
||||||
map String?
|
title String
|
||||||
description String?
|
matchType String @default("community")
|
||||||
scoreA Int?
|
map String?
|
||||||
scoreB Int?
|
description String?
|
||||||
|
scoreA Int?
|
||||||
teamAId String?
|
scoreB Int?
|
||||||
teamA Team? @relation("MatchTeamA", fields: [teamAId], references: [id])
|
|
||||||
|
teamAId String?
|
||||||
teamBId String?
|
teamA Team? @relation("MatchTeamA", fields: [teamAId], references: [id])
|
||||||
teamB Team? @relation("MatchTeamB", fields: [teamBId], references: [id])
|
|
||||||
|
teamBId String?
|
||||||
teamAUsers User[] @relation("TeamAPlayers")
|
teamB Team? @relation("MatchTeamB", fields: [teamBId], references: [id])
|
||||||
teamBUsers User[] @relation("TeamBPlayers")
|
|
||||||
|
teamAUsers User[] @relation("TeamAPlayers")
|
||||||
filePath String?
|
teamBUsers User[] @relation("TeamBPlayers")
|
||||||
demoFile DemoFile?
|
|
||||||
demoDate DateTime?
|
filePath String?
|
||||||
demoData Json?
|
demoFile DemoFile?
|
||||||
|
demoDate DateTime?
|
||||||
players MatchPlayer[]
|
demoData Json?
|
||||||
rankUpdates RankHistory[] @relation("MatchRankHistory")
|
|
||||||
|
players MatchPlayer[]
|
||||||
roundCount Int?
|
rankUpdates RankHistory[] @relation("MatchRankHistory")
|
||||||
roundHistory Json?
|
|
||||||
winnerTeam String?
|
roundCount Int?
|
||||||
|
roundHistory Json?
|
||||||
bestOf Int @default(3) // 1 | 3 | 5 – app-seitig validieren
|
winnerTeam String?
|
||||||
matchDate DateTime? // geplante Startzeit (separat von demoDate)
|
|
||||||
mapVote MapVote?
|
bestOf Int @default(3) // 1 | 3 | 5 – app-seitig validieren
|
||||||
|
matchDate DateTime? // geplante Startzeit (separat von demoDate)
|
||||||
createdAt DateTime @default(now())
|
mapVote MapVote?
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
schedule Schedule?
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
|
schedule Schedule?
|
||||||
}
|
|
||||||
|
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
|
||||||
model MatchPlayer {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
steamId String
|
model MatchPlayer {
|
||||||
matchId String
|
id String @id @default(uuid())
|
||||||
teamId String?
|
steamId String
|
||||||
team Team? @relation(fields: [teamId], references: [id])
|
matchId String
|
||||||
|
teamId String?
|
||||||
match Match @relation(fields: [matchId], references: [id])
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
user User @relation(fields: [steamId], references: [steamId])
|
|
||||||
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
stats PlayerStats?
|
user User @relation(fields: [steamId], references: [steamId])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
stats PlayerStats?
|
||||||
|
|
||||||
@@unique([matchId, steamId])
|
createdAt DateTime @default(now())
|
||||||
}
|
|
||||||
|
@@unique([matchId, steamId])
|
||||||
model PlayerStats {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
matchId String
|
model PlayerStats {
|
||||||
steamId String
|
id String @id @default(uuid())
|
||||||
|
matchId String
|
||||||
kills Int
|
steamId String
|
||||||
assists Int
|
|
||||||
deaths Int
|
kills Int
|
||||||
headshotPct Float
|
assists Int
|
||||||
|
deaths Int
|
||||||
totalDamage Float @default(0)
|
headshotPct Float
|
||||||
utilityDamage Int @default(0)
|
|
||||||
flashAssists Int @default(0)
|
totalDamage Float @default(0)
|
||||||
mvps Int @default(0)
|
utilityDamage Int @default(0)
|
||||||
mvpEliminations Int @default(0)
|
flashAssists Int @default(0)
|
||||||
mvpDefuse Int @default(0)
|
mvps Int @default(0)
|
||||||
mvpPlant Int @default(0)
|
mvpEliminations Int @default(0)
|
||||||
knifeKills Int @default(0)
|
mvpDefuse Int @default(0)
|
||||||
zeusKills Int @default(0)
|
mvpPlant Int @default(0)
|
||||||
wallbangKills Int @default(0)
|
knifeKills Int @default(0)
|
||||||
smokeKills Int @default(0)
|
zeusKills Int @default(0)
|
||||||
headshots Int @default(0)
|
wallbangKills Int @default(0)
|
||||||
noScopes Int @default(0)
|
smokeKills Int @default(0)
|
||||||
blindKills Int @default(0)
|
headshots Int @default(0)
|
||||||
|
noScopes Int @default(0)
|
||||||
aim Int @default(0)
|
blindKills Int @default(0)
|
||||||
|
|
||||||
oneK Int @default(0)
|
aim Int @default(0)
|
||||||
twoK Int @default(0)
|
|
||||||
threeK Int @default(0)
|
oneK Int @default(0)
|
||||||
fourK Int @default(0)
|
twoK Int @default(0)
|
||||||
fiveK Int @default(0)
|
threeK Int @default(0)
|
||||||
|
fourK Int @default(0)
|
||||||
rankOld Int?
|
fiveK Int @default(0)
|
||||||
rankNew Int?
|
|
||||||
rankChange Int?
|
rankOld Int?
|
||||||
winCount Int?
|
rankNew Int?
|
||||||
|
rankChange Int?
|
||||||
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
|
winCount Int?
|
||||||
|
|
||||||
@@unique([matchId, steamId])
|
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
|
||||||
}
|
|
||||||
|
@@unique([matchId, steamId])
|
||||||
model RankHistory {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
steamId String
|
model RankHistory {
|
||||||
matchId String?
|
id String @id @default(uuid())
|
||||||
|
steamId String
|
||||||
rankOld Int
|
matchId String?
|
||||||
rankNew Int
|
|
||||||
delta Int
|
rankOld Int
|
||||||
winCount Int
|
rankNew Int
|
||||||
|
delta Int
|
||||||
createdAt DateTime @default(now())
|
winCount Int
|
||||||
|
|
||||||
user User @relation("UserRankHistory", fields: [steamId], references: [steamId])
|
createdAt DateTime @default(now())
|
||||||
match Match? @relation("MatchRankHistory", fields: [matchId], references: [id])
|
|
||||||
}
|
user User @relation("UserRankHistory", fields: [steamId], references: [steamId])
|
||||||
|
match Match? @relation("MatchRankHistory", fields: [matchId], references: [id])
|
||||||
model Schedule {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
title String
|
model Schedule {
|
||||||
description String?
|
id String @id @default(uuid())
|
||||||
map String?
|
title String
|
||||||
date DateTime
|
description String?
|
||||||
status ScheduleStatus @default(PENDING)
|
map String?
|
||||||
|
date DateTime
|
||||||
teamAId String?
|
status ScheduleStatus @default(PENDING)
|
||||||
teamA Team? @relation("ScheduleTeamA", fields: [teamAId], references: [id])
|
|
||||||
|
teamAId String?
|
||||||
teamBId String?
|
teamA Team? @relation("ScheduleTeamA", fields: [teamAId], references: [id])
|
||||||
teamB Team? @relation("ScheduleTeamB", fields: [teamBId], references: [id])
|
|
||||||
|
teamBId String?
|
||||||
createdById String
|
teamB Team? @relation("ScheduleTeamB", fields: [teamBId], references: [id])
|
||||||
createdBy User @relation("CreatedSchedules", fields: [createdById], references: [steamId])
|
|
||||||
|
createdById String
|
||||||
confirmedById String?
|
createdBy User @relation("CreatedSchedules", fields: [createdById], references: [steamId])
|
||||||
confirmedBy User? @relation("ConfirmedSchedules", fields: [confirmedById], references: [steamId])
|
|
||||||
|
confirmedById String?
|
||||||
linkedMatchId String? @unique
|
confirmedBy User? @relation("ConfirmedSchedules", fields: [confirmedById], references: [steamId])
|
||||||
linkedMatch Match? @relation(fields: [linkedMatchId], references: [id])
|
|
||||||
|
linkedMatchId String? @unique
|
||||||
createdAt DateTime @default(now())
|
linkedMatch Match? @relation(fields: [linkedMatchId], references: [id])
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
enum ScheduleStatus {
|
}
|
||||||
PENDING
|
|
||||||
CONFIRMED
|
enum ScheduleStatus {
|
||||||
DECLINED
|
PENDING
|
||||||
CANCELLED
|
CONFIRMED
|
||||||
COMPLETED
|
DECLINED
|
||||||
}
|
CANCELLED
|
||||||
|
COMPLETED
|
||||||
//
|
}
|
||||||
// ──────────────────────────────────────────────
|
|
||||||
// 📦 Demo-Dateien & CS2 Requests
|
//
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
//
|
// 📦 Demo-Dateien & CS2 Requests
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
model DemoFile {
|
//
|
||||||
id String @id @default(uuid())
|
|
||||||
matchId String @unique
|
model DemoFile {
|
||||||
steamId String
|
id String @id @default(uuid())
|
||||||
fileName String @unique
|
matchId String @unique
|
||||||
filePath String
|
steamId String
|
||||||
parsed Boolean @default(false)
|
fileName String @unique
|
||||||
|
filePath String
|
||||||
createdAt DateTime @default(now())
|
parsed Boolean @default(false)
|
||||||
|
|
||||||
match Match @relation(fields: [matchId], references: [id])
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [steamId], references: [steamId])
|
|
||||||
}
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
|
user User @relation(fields: [steamId], references: [steamId])
|
||||||
model ServerRequest {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
steamId String
|
model ServerRequest {
|
||||||
matchId String
|
id String @id @default(uuid())
|
||||||
reservationId BigInt
|
steamId String
|
||||||
tvPort BigInt
|
matchId String
|
||||||
processed Boolean @default(false)
|
reservationId BigInt
|
||||||
failed Boolean @default(false)
|
tvPort BigInt
|
||||||
|
processed Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
failed Boolean @default(false)
|
||||||
|
|
||||||
user User @relation("MatchRequests", fields: [steamId], references: [steamId])
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([steamId, matchId])
|
user User @relation("MatchRequests", fields: [steamId], references: [steamId])
|
||||||
}
|
|
||||||
|
@@unique([steamId, matchId])
|
||||||
// ──────────────────────────────────────────────
|
}
|
||||||
// 🗺️ Map-Vote
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
// 🗺️ Map-Vote
|
||||||
enum MapVoteAction {
|
// ──────────────────────────────────────────────
|
||||||
BAN
|
|
||||||
PICK
|
enum MapVoteAction {
|
||||||
DECIDER
|
BAN
|
||||||
}
|
PICK
|
||||||
|
DECIDER
|
||||||
model MapVote {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
matchId String @unique
|
model MapVote {
|
||||||
match Match @relation(fields: [matchId], references: [id])
|
id String @id @default(uuid())
|
||||||
|
matchId String @unique
|
||||||
bestOf Int @default(3)
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
mapPool String[]
|
|
||||||
currentIdx Int @default(0)
|
bestOf Int @default(3)
|
||||||
locked Boolean @default(false)
|
mapPool String[]
|
||||||
opensAt DateTime?
|
currentIdx Int @default(0)
|
||||||
|
locked Boolean @default(false)
|
||||||
leadMinutes Int @default(60)
|
opensAt DateTime?
|
||||||
|
|
||||||
adminEditingBy String?
|
leadMinutes Int @default(60)
|
||||||
adminEditingSince DateTime?
|
|
||||||
|
adminEditingBy String?
|
||||||
steps MapVoteStep[]
|
adminEditingSince DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
steps MapVoteStep[]
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
model MapVoteStep {
|
}
|
||||||
id String @id @default(uuid())
|
|
||||||
voteId String
|
model MapVoteStep {
|
||||||
order Int
|
id String @id @default(uuid())
|
||||||
action MapVoteAction
|
voteId String
|
||||||
|
order Int
|
||||||
teamId String?
|
action MapVoteAction
|
||||||
team Team? @relation("VoteStepTeam", fields: [teamId], references: [id])
|
|
||||||
|
teamId String?
|
||||||
map String?
|
team Team? @relation("VoteStepTeam", fields: [teamId], references: [id])
|
||||||
chosenAt DateTime?
|
|
||||||
chosenBy String?
|
map String?
|
||||||
chooser User? @relation("VoteStepChooser", fields: [chosenBy], references: [steamId])
|
chosenAt DateTime?
|
||||||
|
chosenBy String?
|
||||||
vote MapVote @relation(fields: [voteId], references: [id])
|
chooser User? @relation("VoteStepChooser", fields: [chosenBy], references: [steamId])
|
||||||
|
|
||||||
@@unique([voteId, order])
|
vote MapVote @relation(fields: [voteId], references: [id])
|
||||||
@@index([teamId])
|
|
||||||
@@index([chosenBy])
|
@@unique([voteId, order])
|
||||||
}
|
@@index([teamId])
|
||||||
|
@@index([chosenBy])
|
||||||
model MatchReady {
|
}
|
||||||
matchId String
|
|
||||||
steamId String
|
model MatchReady {
|
||||||
acceptedAt DateTime @default(now())
|
matchId String
|
||||||
|
steamId String
|
||||||
match Match @relation("MatchReadyMatch", fields: [matchId], references: [id])
|
acceptedAt DateTime @default(now())
|
||||||
user User @relation("MatchReadyUser", fields: [steamId], references: [steamId])
|
|
||||||
|
match Match @relation("MatchReadyMatch", fields: [matchId], references: [id])
|
||||||
@@id([matchId, steamId])
|
user User @relation("MatchReadyUser", fields: [steamId], references: [steamId])
|
||||||
@@index([steamId])
|
|
||||||
}
|
@@id([matchId, steamId])
|
||||||
|
@@index([steamId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 🛠️ Server-Konfiguration & Pterodactyl
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
model ServerConfig {
|
||||||
|
id String @id
|
||||||
|
serverIp String
|
||||||
|
serverPassword String? // ⬅️ neu
|
||||||
|
pterodactylServerId String
|
||||||
|
pterodactylServerApiKey String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<Tab name="Spielpläne" href="/admin/matches" />
|
<Tab name="Spielpläne" href="/admin/matches" />
|
||||||
<Tab name="Privacy" href="/admin/privacy" />
|
<Tab name="Privacy" href="/admin/privacy" />
|
||||||
<Tab name="Teams" href="/admin/teams" />
|
<Tab name="Teams" href="/admin/teams" />
|
||||||
|
<Tab name="Serververwaltung" href="/admin/server" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
88
src/app/admin/server/page.tsx
Normal file
88
src/app/admin/server/page.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// /src/app/admin/server/page.tsx
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/app/lib/auth'
|
||||||
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import Card from '@/app/components/Card'
|
||||||
|
import ServerView from '@/app/components/admin/server/ServerView'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
async function ensureConfig() {
|
||||||
|
const cfg = await prisma.serverConfig.upsert({
|
||||||
|
where: { id: 'default' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: 'default',
|
||||||
|
serverIp: '',
|
||||||
|
serverPassword: '', // ⬅️ neu
|
||||||
|
pterodactylServerId: '',
|
||||||
|
pterodactylServerApiKey: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminServerPage(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const me = session?.user as any | undefined
|
||||||
|
|
||||||
|
if (!me?.steamId || !me?.isAdmin) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cfg, meUser] = await Promise.all([
|
||||||
|
ensureConfig(),
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { steamId: me.steamId },
|
||||||
|
select: { steamId: true, pterodactylClientApiKey: true, name: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
async function save(formData: FormData) {
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
const serverIp = String(formData.get('serverIp') ?? '').trim()
|
||||||
|
const serverId = String(formData.get('serverId') ?? '').trim()
|
||||||
|
const serverApiKey = String(formData.get('serverApiKey') ?? '').trim()
|
||||||
|
const serverPassword = String(formData.get('serverPassword') ?? '').trim() // ⬅️ neu
|
||||||
|
const clientApiKey = String(formData.get('clientApiKey') ?? '').trim()
|
||||||
|
|
||||||
|
if (!serverIp) throw new Error('Server-IP darf nicht leer sein.')
|
||||||
|
if (!serverId) throw new Error('Pterodactyl Server-ID darf nicht leer sein.')
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.serverConfig.update({
|
||||||
|
where: { id: 'default' },
|
||||||
|
data: {
|
||||||
|
serverIp,
|
||||||
|
pterodactylServerId: serverId,
|
||||||
|
...(serverApiKey ? { pterodactylServerApiKey: serverApiKey } : {}),
|
||||||
|
...(serverPassword ? { serverPassword } : {}), // ⬅️ nur setzen, wenn übergeben
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (clientApiKey) {
|
||||||
|
await tx.user.update({
|
||||||
|
where: { steamId: me?.steamId },
|
||||||
|
data: { pterodactylClientApiKey: clientApiKey },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/admin/server')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Serververwaltung" description="Hier kannst du die Servereinstellungen ändern" maxWidth="auto">
|
||||||
|
<ServerView
|
||||||
|
cfg={cfg}
|
||||||
|
meUser={meUser}
|
||||||
|
meSteamId={me.steamId}
|
||||||
|
onSave={save}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/app/api/cs2/server/route.ts
Normal file
60
src/app/api/cs2/server/route.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// /src/app/api/cs2/server/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'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
function buildConnectHref(serverIp: string, password?: string | null, port = 27015) {
|
||||||
|
const pass = (password ?? '').trim()
|
||||||
|
return pass
|
||||||
|
? `steam://connect/${serverIp}:${port}/${pass}`
|
||||||
|
: `steam://connect/${serverIp}:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const me = session?.user as { steamId?: string } | undefined
|
||||||
|
if (!me?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const matchId = searchParams.get('matchId') || undefined
|
||||||
|
|
||||||
|
// 1) Config aus DB
|
||||||
|
const cfg = await prisma.serverConfig.findUnique({ where: { id: 'default' } })
|
||||||
|
if (!cfg?.serverIp) {
|
||||||
|
return NextResponse.json({ error: 'server not configured' }, { status: 503 })
|
||||||
|
}
|
||||||
|
const port = Number(process.env.NEXT_PUBLIC_GAME_PORT ?? '27015') || 27015
|
||||||
|
|
||||||
|
// 2) Optional: nur Teilnehmer des Matches bekommen den Link
|
||||||
|
if (matchId) {
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
include: {
|
||||||
|
teamA: { select: { leaderId: true } },
|
||||||
|
teamB: { select: { leaderId: true } },
|
||||||
|
teamAUsers: { select: { steamId: true } },
|
||||||
|
teamBUsers: { select: { steamId: true } },
|
||||||
|
players: { select: { steamId: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!match) return NextResponse.json({ error: 'match not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const ids = new Set<string>()
|
||||||
|
match.teamAUsers.forEach(u => ids.add(u.steamId))
|
||||||
|
match.teamBUsers.forEach(u => ids.add(u.steamId))
|
||||||
|
match.players.forEach(p => ids.add(p.steamId))
|
||||||
|
if (match.teamA?.leaderId) ids.add(match.teamA.leaderId)
|
||||||
|
if (match.teamB?.leaderId) ids.add(match.teamB.leaderId)
|
||||||
|
|
||||||
|
if (!ids.has(me.steamId!)) {
|
||||||
|
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectHref = buildConnectHref(cfg.serverIp, cfg.serverPassword ?? undefined, port)
|
||||||
|
return NextResponse.json({ connectHref }, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
}
|
||||||
98
src/app/api/cs2/server/send-command/route.ts
Normal file
98
src/app/api/cs2/server/send-command/route.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// /src/app/api/cs2/server/send-command/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'
|
||||||
|
|
||||||
|
export const runtime = 'nodejs'
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
function buildPanelUrl(base: string, serverId: string) {
|
||||||
|
const u = new URL(base.includes('://') ? base : `https://${base}`)
|
||||||
|
// /api/client/servers/:id/command
|
||||||
|
const cleaned = (u.pathname || '').replace(/\/+$/,'')
|
||||||
|
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
|
||||||
|
return u.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const me = session?.user as { steamId?: string; isAdmin?: boolean } | undefined
|
||||||
|
if (!me?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||||
|
if (!me?.isAdmin) return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
let body: { command?: string; serverId?: string } = {}
|
||||||
|
try { body = await req.json() } catch {}
|
||||||
|
const command = (body.command ?? '').trim()
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return NextResponse.json({ error: 'command required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel-Base-URL aus ENV
|
||||||
|
const panelBase =
|
||||||
|
process.env.PTERODACTYL_PANEL_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
|
||||||
|
''
|
||||||
|
if (!panelBase) {
|
||||||
|
return NextResponse.json({ error: 'PTERODACTYL_PANEL_URL not set' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerId (global aus Config, optional via Body überschreibbar)
|
||||||
|
const cfg = await prisma.serverConfig.findUnique({
|
||||||
|
where: { id: 'default' },
|
||||||
|
select: { pterodactylServerId: true },
|
||||||
|
})
|
||||||
|
const serverId = (body.serverId ?? cfg?.pterodactylServerId ?? '').trim()
|
||||||
|
if (!serverId) {
|
||||||
|
return NextResponse.json({ error: 'serverId not configured' }, { status: 503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Userbasierter Client-API-Key
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { steamId: me.steamId! },
|
||||||
|
select: { pterodactylClientApiKey: true },
|
||||||
|
})
|
||||||
|
const clientKey = (user?.pterodactylClientApiKey ?? '').trim()
|
||||||
|
if (!clientKey) {
|
||||||
|
return NextResponse.json({ error: 'missing client api key for user' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildPanelUrl(panelBase, serverId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${clientKey}`,
|
||||||
|
Accept: 'Application/vnd.pterodactyl.v1+json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ command }),
|
||||||
|
// Panel ist „extern“ – niemals cachen
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pterodactyl antwortet häufig mit 204 No Content
|
||||||
|
if (res.status === 204) {
|
||||||
|
return NextResponse.json({ ok: true, status: 204 }, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
if (!res.ok) {
|
||||||
|
let errPayload: any = undefined
|
||||||
|
try { errPayload = JSON.parse(text) } catch {}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'pterodactyl_error', status: res.status, body: errPayload ?? text },
|
||||||
|
{ status: 502 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: any = {}
|
||||||
|
try { json = JSON.parse(text) } catch { json = { body: text } }
|
||||||
|
return NextResponse.json({ ok: true, status: res.status, response: json }, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ error: e?.message ?? 'request_failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,151 +0,0 @@
|
|||||||
// /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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -22,6 +22,81 @@ const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
|
|||||||
|
|
||||||
const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
|
const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
|
||||||
|
|
||||||
|
function makeRandomMatchId() {
|
||||||
|
try {
|
||||||
|
// 9–10-stellige ID (>= 100_000_000) – Obergrenze exklusiv
|
||||||
|
return typeof randomInt === 'function'
|
||||||
|
? randomInt(100_000_000, 2_147_483_647) // bis INT32_MAX
|
||||||
|
: (Math.floor(Math.random() * (2_147_483_647 - 100_000_000)) + 100_000_000)
|
||||||
|
} catch {
|
||||||
|
return Math.floor(Math.random() * 1_000_000_000) + 100_000_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPteroClientUrl(base: string, serverId: string) {
|
||||||
|
const u = new URL(base.includes('://') ? base : `https://${base}`)
|
||||||
|
const cleaned = (u.pathname || '').replace(/\/+$/, '')
|
||||||
|
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
|
||||||
|
return u.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendServerCommand(command: string) {
|
||||||
|
try {
|
||||||
|
const panelBase =
|
||||||
|
process.env.PTERODACTYL_PANEL_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
|
||||||
|
''
|
||||||
|
if (!panelBase) {
|
||||||
|
console.warn('[mapvote] PTERODACTYL_PANEL_URL fehlt – Command wird nicht gesendet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⬇️ Client-API-Key NUR aus .env ziehen
|
||||||
|
const clientApiKey = process.env.PTERODACTYL_CLIENT_API || ''
|
||||||
|
if (!clientApiKey) {
|
||||||
|
console.warn('[mapvote] PTERODACTYL_CLIENT_API fehlt – Command wird nicht gesendet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-ID weiterhin aus DB (ServerConfig)
|
||||||
|
const cfg = await prisma.serverConfig.findUnique({
|
||||||
|
where: { id: 'default' },
|
||||||
|
select: { pterodactylServerId: true },
|
||||||
|
})
|
||||||
|
if (!cfg?.pterodactylServerId) {
|
||||||
|
console.warn('[mapvote] pterodactylServerId fehlt in ServerConfig – Command wird nicht gesendet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildPteroClientUrl(panelBase, cfg.pterodactylServerId)
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${clientApiKey}`, // ✅ Client-API-Key
|
||||||
|
Accept: 'Application/vnd.pterodactyl.v1+json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ command }),
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
console.log('[mapvote] Command OK (204):', command)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text().catch(() => '')
|
||||||
|
console.error('[mapvote] Command fehlgeschlagen:', res.status, t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('[mapvote] Command OK:', command)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[mapvote] Command-Fehler:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Admin-Edit-Flag setzen/zurücksetzen
|
// Admin-Edit-Flag setzen/zurücksetzen
|
||||||
async function setAdminEdit(voteId: string, by: string | null) {
|
async function setAdminEdit(voteId: string, by: string | null) {
|
||||||
return prisma.mapVote.update({
|
return prisma.mapVote.update({
|
||||||
@ -316,6 +391,8 @@ function toDeMapName(key: string) {
|
|||||||
if (key.startsWith('de_')) return key
|
if (key.startsWith('de_')) return key
|
||||||
return `de_${key}`
|
return `de_${key}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⬇️ buildMatchJson anpassen: matchid statt "" → zufälliger Integer
|
||||||
function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
|
function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
|
||||||
const bestOf = match.bestOf ?? state.bestOf ?? 3
|
const bestOf = match.bestOf ?? state.bestOf ?? 3
|
||||||
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
|
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
|
||||||
@ -333,8 +410,11 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
|
|||||||
const team1Players = playersMapFromList(match.teamA?.players)
|
const team1Players = playersMapFromList(match.teamA?.players)
|
||||||
const team2Players = playersMapFromList(match.teamB?.players)
|
const team2Players = playersMapFromList(match.teamB?.players)
|
||||||
|
|
||||||
|
// 👇 hier neu: zufällige Integer-ID
|
||||||
|
const rndId = makeRandomMatchId()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
matchid: "",
|
matchid: rndId, // vorher: ""
|
||||||
team1: { name: team1Name, players: team1Players },
|
team1: { name: team1Name, players: team1Players },
|
||||||
team2: { name: team2Name, players: team2Players },
|
team2: { name: team2Name, players: team2Players },
|
||||||
num_maps: bestOf,
|
num_maps: bestOf,
|
||||||
@ -352,7 +432,7 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
|
|||||||
|
|
||||||
async function exportMatchToSftpDirect(match: any, vote: any) {
|
async function exportMatchToSftpDirect(match: any, vote: any) {
|
||||||
try {
|
try {
|
||||||
const SFTPClient = (await import('ssh2-sftp-client')).default // dyn. import
|
const SFTPClient = (await import('ssh2-sftp-client')).default
|
||||||
const mLike: MatchLike = {
|
const mLike: MatchLike = {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
bestOf: match.bestOf ?? vote.bestOf ?? 3,
|
bestOf: match.bestOf ?? vote.bestOf ?? 3,
|
||||||
@ -367,15 +447,13 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
|
|||||||
locked: vote.locked,
|
locked: vote.locked,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sLike.locked) return // nur exportieren, wenn locked
|
if (!sLike.locked) return
|
||||||
|
|
||||||
const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3
|
const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3
|
||||||
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
|
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
|
||||||
if (chosen.length < bestOf) return // vollständig sicherstellen
|
if (chosen.length < bestOf) return
|
||||||
|
|
||||||
const json = buildMatchJson(mLike, sLike)
|
const json = buildMatchJson(mLike, sLike)
|
||||||
const jsonStr = JSON.stringify(json, null, 2)
|
const jsonStr = JSON.stringify(json, null, 2)
|
||||||
|
|
||||||
const filename = `${match.id}.json`
|
const filename = `${match.id}.json`
|
||||||
|
|
||||||
const url = process.env.PTERO_SERVER_SFTP_URL || ''
|
const url = process.env.PTERO_SERVER_SFTP_URL || ''
|
||||||
@ -405,11 +483,17 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
|
|||||||
await sftp.end()
|
await sftp.end()
|
||||||
|
|
||||||
console.log(`[mapvote] Export OK → ${remotePath}`)
|
console.log(`[mapvote] Export OK → ${remotePath}`)
|
||||||
|
|
||||||
|
// 👇 NACH ERFOLGREICHEM UPLOAD: Match in CS2-Plugin laden
|
||||||
|
// Laut Vorgabe nur die JSON-Datei als Argument übergeben:
|
||||||
|
await sendServerCommand(`matchzy_loadmatch ${filename}`)
|
||||||
|
// (Falls dein Plugin den absoluten Pfad erwartet, nimm stattdessen: `matchzy_loadmatch ${remotePath}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[mapvote] Export fehlgeschlagen:', err)
|
console.error('[mapvote] Export fehlgeschlagen:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---------- kleine Helfer für match-ready Payload ---------- */
|
/* ---------- kleine Helfer für match-ready Payload ---------- */
|
||||||
|
|
||||||
function deriveChosenSteps(vote: any) {
|
function deriveChosenSteps(vote: any) {
|
||||||
|
|||||||
@ -6,12 +6,27 @@ import { useSession } from 'next-auth/react'
|
|||||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
import MatchReadyOverlay from './MatchReadyOverlay'
|
import MatchReadyOverlay from './MatchReadyOverlay'
|
||||||
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore'
|
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore'
|
||||||
|
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore' // ⬅️ neu
|
||||||
|
|
||||||
/**
|
// ---- kleiner In-Memory Cache für connectHref pro matchId
|
||||||
* Erwartet SSE-Events:
|
const CONNECT_CACHE = new Map<string | undefined, string>()
|
||||||
* - 'match-ready' { matchId, firstMap:{label,bg}, participants:string[] }
|
|
||||||
* - 'map-vote-updated' { matchId, locked, bestOf, steps, mapVisuals, teams:{teamA,teamB} }
|
async function getConnectHref(matchId?: string): Promise<string | null> {
|
||||||
*/
|
if (CONNECT_CACHE.has(matchId)) return CONNECT_CACHE.get(matchId) || null
|
||||||
|
try {
|
||||||
|
const qs = matchId ? `?matchId=${encodeURIComponent(matchId)}` : ''
|
||||||
|
const r = await fetch(`/api/cs2/server${qs}`, { cache: 'no-store' })
|
||||||
|
if (!r.ok) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const j = await r.json()
|
||||||
|
const href: string | undefined = j?.connectHref
|
||||||
|
if (href) CONNECT_CACHE.set(matchId, href)
|
||||||
|
return href ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReadyOverlayHost() {
|
export default function ReadyOverlayHost() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -21,7 +36,9 @@ export default function ReadyOverlayHost() {
|
|||||||
|
|
||||||
const { open, data, showWithDelay, hide } = useReadyOverlayStore()
|
const { open, data, showWithDelay, hide } = useReadyOverlayStore()
|
||||||
|
|
||||||
// --- LocalStorage-Flag: pro Match nur einmal anzeigen ---
|
const setRoster = useMatchRosterStore(s => s.setRoster) // ⬅️ neu
|
||||||
|
const clearRoster = useMatchRosterStore(s => s.clearRoster) // ⬅️ neu
|
||||||
|
|
||||||
const isAccepted = (matchId: string) =>
|
const isAccepted = (matchId: string) =>
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
window.localStorage.getItem(`match:${matchId}:readyAccepted`) === '1'
|
window.localStorage.getItem(`match:${matchId}:readyAccepted`) === '1'
|
||||||
@ -32,7 +49,7 @@ export default function ReadyOverlayHost() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ableitung firstMap + participants aus 'map-vote-updated' ---
|
// Aus 'map-vote-updated' minimal Summary ableiten (Map 1 + Teilnehmer)
|
||||||
function deriveReadySummary(payload: any) {
|
function deriveReadySummary(payload: any) {
|
||||||
const matchId: string | undefined = payload?.matchId
|
const matchId: string | undefined = payload?.matchId
|
||||||
if (!matchId) return null
|
if (!matchId) return null
|
||||||
@ -65,49 +82,71 @@ export default function ReadyOverlayHost() {
|
|||||||
return { matchId, firstMap: { key, label, bg }, participants }
|
return { matchId, firstMap: { key, label, bg }, participants }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SSE: 'match-ready' / 'map-vote-updated' öffnen das Overlay ---
|
// Events: 'match-ready' & 'map-vote-updated'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent || !mySteamId) return
|
if (!lastEvent || !mySteamId) return
|
||||||
|
|
||||||
if (lastEvent.type === 'match-ready') {
|
if (lastEvent.type === 'match-ready') {
|
||||||
const m = lastEvent.payload?.matchId
|
(async () => {
|
||||||
const participants: string[] = lastEvent.payload?.participants ?? []
|
const m: string | undefined = lastEvent.payload?.matchId
|
||||||
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
|
const participants: string[] = lastEvent.payload?.participants ?? []
|
||||||
|
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
|
||||||
|
|
||||||
const label = lastEvent.payload?.firstMap?.label ?? '?'
|
// ⬇️ Roster persistent speichern (für Reconnect-Banner)
|
||||||
const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
|
setRoster(participants)
|
||||||
|
|
||||||
showWithDelay(
|
const label = lastEvent.payload?.firstMap?.label ?? '?'
|
||||||
{
|
const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
|
||||||
matchId: m,
|
|
||||||
mapLabel: label,
|
const connectHref =
|
||||||
mapBg: bg,
|
(await getConnectHref(m)) ||
|
||||||
nextHref: `/match-details/${m}/radar`,
|
process.env.NEXT_PUBLIC_CONNECT_HREF ||
|
||||||
},
|
null
|
||||||
3000
|
|
||||||
)
|
showWithDelay(
|
||||||
|
{
|
||||||
|
matchId: m,
|
||||||
|
mapLabel: label,
|
||||||
|
mapBg: bg,
|
||||||
|
nextHref: `/match-details/${m}/radar`,
|
||||||
|
connectHref: connectHref ?? undefined,
|
||||||
|
},
|
||||||
|
3000
|
||||||
|
)
|
||||||
|
})()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastEvent.type === 'map-vote-updated') {
|
if (lastEvent.type === 'map-vote-updated') {
|
||||||
const summary = deriveReadySummary(lastEvent.payload)
|
(async () => {
|
||||||
if (!summary) return
|
const summary = deriveReadySummary(lastEvent.payload)
|
||||||
const { matchId: m, firstMap, participants } = summary
|
if (!summary) return
|
||||||
if (!participants.includes(mySteamId) || isAccepted(m)) return
|
const { matchId: m, firstMap, participants } = summary
|
||||||
|
if (!participants.includes(mySteamId) || isAccepted(m)) return
|
||||||
|
|
||||||
showWithDelay(
|
// ⬇️ Roster persistent speichern
|
||||||
{
|
setRoster(participants)
|
||||||
matchId: m,
|
|
||||||
mapLabel: firstMap.label,
|
const connectHref =
|
||||||
mapBg: firstMap.bg,
|
(await getConnectHref(m)) ||
|
||||||
nextHref: `/match-details/${m}/radar`,
|
process.env.NEXT_PUBLIC_CONNECT_HREF ||
|
||||||
},
|
null
|
||||||
3000
|
|
||||||
)
|
showWithDelay(
|
||||||
|
{
|
||||||
|
matchId: m,
|
||||||
|
mapLabel: firstMap.label,
|
||||||
|
mapBg: firstMap.bg,
|
||||||
|
nextHref: `/match-details/${m}/radar`,
|
||||||
|
connectHref: connectHref ?? undefined,
|
||||||
|
},
|
||||||
|
3000
|
||||||
|
)
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
}, [lastEvent, mySteamId, showWithDelay])
|
}, [lastEvent, mySteamId, showWithDelay, setRoster])
|
||||||
|
|
||||||
// --- Reset-Event: Overlay schließen & Flag löschen ---
|
// Reset schließt Overlay & löscht Flag + Roster
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
if (lastEvent.type === 'map-vote-reset') {
|
if (lastEvent.type === 'map-vote-reset') {
|
||||||
@ -115,27 +154,23 @@ export default function ReadyOverlayHost() {
|
|||||||
if (m && typeof window !== 'undefined') {
|
if (m && typeof window !== 'undefined') {
|
||||||
window.localStorage.removeItem(`match:${m}:readyAccepted`)
|
window.localStorage.removeItem(`match:${m}:readyAccepted`)
|
||||||
}
|
}
|
||||||
|
clearRoster() // ⬅️ neu
|
||||||
if (open) hide()
|
if (open) hide()
|
||||||
}
|
}
|
||||||
}, [lastEvent, open, hide])
|
}, [lastEvent, open, hide, clearRoster])
|
||||||
|
|
||||||
if (!open || !data) return null
|
if (!open || !data) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MatchReadyOverlay
|
<MatchReadyOverlay
|
||||||
open={open}
|
open={open}
|
||||||
matchId={data.matchId} // ✅ neu: fürs Ready-Polling im Overlay
|
matchId={data.matchId}
|
||||||
mapLabel={data.mapLabel}
|
mapLabel={data.mapLabel}
|
||||||
mapBg={data.mapBg}
|
mapBg={data.mapBg}
|
||||||
onAccept={async () => {
|
onAccept={async () => {
|
||||||
// Backend optional informieren (Overlay bleibt offen!)
|
|
||||||
// await fetch(`/api/matches/${data.matchId}/ready-accept`, { method: 'POST' }).catch(() => {})
|
|
||||||
markAccepted(data.matchId)
|
markAccepted(data.matchId)
|
||||||
|
|
||||||
// ❌ NICHT mehr schließen oder navigieren
|
|
||||||
// hide()
|
|
||||||
// router.push(data.nextHref ?? `/match-details/${data.matchId}/radar`)
|
|
||||||
}}
|
}}
|
||||||
|
connectHref={data.connectHref}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { usePresenceStore } from '@/app/lib/usePresenceStore'
|
import { usePresenceStore } from '@/app/lib/usePresenceStore'
|
||||||
import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
|
import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
|
||||||
|
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
|
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
|
||||||
|
|
||||||
@ -29,17 +31,46 @@ export default function TelemetrySocket() {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const mySteamId = (session?.user as any)?.steamId ?? null
|
||||||
|
|
||||||
const setSnapshot = usePresenceStore(s => s.setSnapshot)
|
const setSnapshot = usePresenceStore(s => s.setSnapshot)
|
||||||
const setJoin = usePresenceStore(s => s.setJoin)
|
const setJoin = usePresenceStore(s => s.setJoin)
|
||||||
const setLeave = usePresenceStore(s => s.setLeave)
|
const setLeave = usePresenceStore(s => s.setLeave)
|
||||||
|
|
||||||
const setMapKey = useTelemetryStore(s => s.setMapKey)
|
const setMapKey = useTelemetryStore(s => s.setMapKey)
|
||||||
|
const phase = useTelemetryStore(s => s.phase)
|
||||||
|
const setPhase = useTelemetryStore(s => s.setPhase)
|
||||||
|
|
||||||
// interne Refs für saubere Handler
|
const rosterSet = useMatchRosterStore(s => s.roster)
|
||||||
|
|
||||||
|
// 👇 Tracke, ob ICH gerade auf dem Server bin
|
||||||
|
const [myConnected, setMyConnected] = useState(false)
|
||||||
|
|
||||||
|
// internes Dismiss-Flag fürs Banner (pro Mount)
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
|
||||||
|
// connectHref optional dynamisch vom Backend ziehen
|
||||||
|
const [connectHref, setConnectHref] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// WS-Reconnect
|
||||||
const aliveRef = useRef(true)
|
const aliveRef = useRef(true)
|
||||||
const retryRef = useRef<number | null>(null)
|
const retryRef = useRef<number | null>(null)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
|
||||||
|
// Connect-Href laden (Passwort etc. aus DB)
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/cs2/server', { cache: 'no-store' })
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json()
|
||||||
|
if (j?.connectHref) setConnectHref(j.connectHref)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
aliveRef.current = true
|
aliveRef.current = true
|
||||||
|
|
||||||
@ -65,20 +96,38 @@ export default function TelemetrySocket() {
|
|||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||||
// kompletter Presence-Snapshot
|
|
||||||
setSnapshot(msg.players)
|
setSnapshot(msg.players)
|
||||||
|
// 👇 bin ich in der aktuellen Players-Liste?
|
||||||
|
if (mySteamId) {
|
||||||
|
const present = msg.players.some((p: any) =>
|
||||||
|
String(p?.steamId ?? p?.steam_id ?? p?.id) === String(mySteamId)
|
||||||
|
)
|
||||||
|
setMyConnected(present)
|
||||||
|
}
|
||||||
} else if (msg.type === 'player_join' && msg.player) {
|
} else if (msg.type === 'player_join' && msg.player) {
|
||||||
setJoin(msg.player)
|
setJoin(msg.player)
|
||||||
|
// 👇 falls ich join:
|
||||||
|
const sid = msg.player?.steamId ?? msg.player?.steam_id ?? msg.player?.id
|
||||||
|
if (mySteamId && String(sid) === String(mySteamId)) setMyConnected(true)
|
||||||
} else if (msg.type === 'player_leave') {
|
} else if (msg.type === 'player_leave') {
|
||||||
const sid = msg.steamId ?? msg.steam_id ?? msg.id
|
const sid = msg.steamId ?? msg.steam_id ?? msg.id
|
||||||
if (sid != null) setLeave(sid)
|
if (sid != null) setLeave(sid)
|
||||||
|
// 👇 falls ich leave:
|
||||||
|
if (mySteamId && String(sid) === String(mySteamId)) setMyConnected(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⬇️ Map-Event aus /telemetry
|
// Map (inkl. optionaler Phase)
|
||||||
if (msg.type === 'map' && typeof msg.name === 'string') {
|
if (msg.type === 'map' && typeof msg.name === 'string') {
|
||||||
const key = msg.name.toLowerCase()
|
const key = msg.name.toLowerCase()
|
||||||
if (process.env.NODE_ENV!=='production') console.debug('[TelemetrySocket] map:', key)
|
if (process.env.NODE_ENV!=='production') console.debug('[TelemetrySocket] map:', key)
|
||||||
setMapKey(key)
|
setMapKey(key)
|
||||||
|
if (typeof msg.phase === 'string') {
|
||||||
|
setPhase(String(msg.phase).toLowerCase() as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reine Phase-Events
|
||||||
|
if (msg.type === 'phase' && typeof msg.phase === 'string') {
|
||||||
|
setPhase(String(msg.phase).toLowerCase() as any)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,7 +138,61 @@ export default function TelemetrySocket() {
|
|||||||
if (retryRef.current) window.clearTimeout(retryRef.current)
|
if (retryRef.current) window.clearTimeout(retryRef.current)
|
||||||
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
|
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
|
||||||
}
|
}
|
||||||
}, [url, setSnapshot, setJoin, setLeave])
|
}, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, mySteamId])
|
||||||
|
|
||||||
return null
|
// Anzeige-Logik Banner:
|
||||||
|
// - Ich bin im Roster (Match wurde geladen & ich bin Teilnehmer)
|
||||||
|
// - Match-Phase live
|
||||||
|
// - Ich bin NICHT connected (disconnect)
|
||||||
|
const meInRoster = !!mySteamId && rosterSet instanceof Set && rosterSet.has(String(mySteamId))
|
||||||
|
const shouldShow = !dismissed && (phase === 'live') && meInRoster && !myConnected
|
||||||
|
|
||||||
|
const connectUri =
|
||||||
|
connectHref // ⬅️ bevorzugt API-Route (inkl. Passwort)
|
||||||
|
|| process.env.NEXT_PUBLIC_STEAM_CONNECT_URI
|
||||||
|
|| process.env.NEXT_PUBLIC_CS2_CONNECT_URI
|
||||||
|
|| 'steam://rungameid/730//+retry' // Fallback
|
||||||
|
|
||||||
|
const handleReconnect = () => {
|
||||||
|
try {
|
||||||
|
window.location.href = connectUri
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* WebSocket-Client selbst rendert nichts */}
|
||||||
|
{shouldShow && (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-[9999] mx-auto mb-3 max-w-3xl">
|
||||||
|
<div className="mx-3 rounded-md bg-neutral-900/95 text-white shadow-lg ring-1 ring-black/10">
|
||||||
|
<div className="p-3 flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold">Match läuft · Reconnect verfügbar</div>
|
||||||
|
<div className="text-xs opacity-80">
|
||||||
|
Du bist im aufgesetzten Match eingetragen. Klicke „Reconnect“, um wieder zu joinen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleReconnect}
|
||||||
|
className="px-3 py-1.5 rounded bg-emerald-500 hover:bg-emerald-600 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="px-2 py-1 rounded bg-neutral-700 hover:bg-neutral-600 text-xs"
|
||||||
|
aria-label="schließen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
// components/UserClips.tsx
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { getClips, type Clip } from '../lib/allstar'
|
|
||||||
|
|
||||||
export default function UserClips({ steamId }: { steamId: string }) {
|
|
||||||
const [clips, setClips] = useState<Clip[]>([])
|
|
||||||
useEffect(() => { getClips(steamId).then(r => setClips(r.clips)) }, [steamId])
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
{clips.map(c => (
|
|
||||||
<video key={c.id} src={c.url} controls className="rounded-lg w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
138
src/app/components/admin/server/ServerView.tsx
Normal file
138
src/app/components/admin/server/ServerView.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
// /src/app/admin/server/ServerView.tsx
|
||||||
|
import Button from '@/app/components/Button' // ⬅️ neu
|
||||||
|
|
||||||
|
type ServerConfigShape = {
|
||||||
|
serverIp: string
|
||||||
|
serverPassword?: string | null
|
||||||
|
pterodactylServerId: string
|
||||||
|
pterodactylServerApiKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeUserShape = {
|
||||||
|
steamId: string
|
||||||
|
name?: string | null
|
||||||
|
pterodactylClientApiKey?: string | null
|
||||||
|
} | null
|
||||||
|
|
||||||
|
export default function ServerView({
|
||||||
|
cfg,
|
||||||
|
meUser,
|
||||||
|
meSteamId,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
cfg: ServerConfigShape
|
||||||
|
meUser: MeUserShape
|
||||||
|
meSteamId: string
|
||||||
|
onSave: (formData: FormData) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const displayName = meUser?.name || meSteamId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Server-Einstellungen</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mb-6">
|
||||||
|
Hier konfigurierst du den Match-Server (global) und deinen persönlichen Pterodactyl <i>Client</i>-API-Key.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action={onSave} className="space-y-6">
|
||||||
|
<fieldset className="rounded-md border border-neutral-200/50 p-4">
|
||||||
|
<legend className="px-2 text-sm font-semibold">Server (global)</legend>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium">Server-IP / Host</span>
|
||||||
|
<input
|
||||||
|
name="serverIp"
|
||||||
|
type="text"
|
||||||
|
defaultValue={cfg.serverIp}
|
||||||
|
required
|
||||||
|
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
|
||||||
|
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
|
||||||
|
placeholder="z. B. 1.2.3.4 oder gameserver.domain.tld"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium">Pterodactyl Server-ID</span>
|
||||||
|
<input
|
||||||
|
name="serverId"
|
||||||
|
type="text"
|
||||||
|
defaultValue={cfg.pterodactylServerId}
|
||||||
|
required
|
||||||
|
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
|
||||||
|
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
|
||||||
|
placeholder="z. B. a1b2c3d4"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium">Pterodactyl <b>Server</b>-API-Key (global)</span>
|
||||||
|
<input
|
||||||
|
name="serverApiKey"
|
||||||
|
type="password"
|
||||||
|
defaultValue=""
|
||||||
|
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
|
||||||
|
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
|
||||||
|
placeholder={cfg.pterodactylServerApiKey ? '•••••••• (gesetzt)' : 'noch nicht gesetzt'}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
Leer lassen, um den bestehenden Key nicht zu überschreiben.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Server-Passwort für Connect */}
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium">Server-Passwort (für Connect)</span>
|
||||||
|
<input
|
||||||
|
name="serverPassword"
|
||||||
|
type="password"
|
||||||
|
defaultValue=""
|
||||||
|
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
|
||||||
|
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
|
||||||
|
placeholder={cfg.serverPassword ? '•••••••• (gesetzt)' : 'noch nicht gesetzt'}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
Leer lassen, um das bestehende Passwort nicht zu überschreiben.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="rounded-md border border-neutral-200/50 p-4">
|
||||||
|
<legend className="px-2 text-sm font-semibold">
|
||||||
|
Dein Pterodactyl <b>Client</b>-API-Key (userbasiert)
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="text-xs text-neutral-500">
|
||||||
|
Eingeloggt als <b>{displayName}</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-medium">Client-API-Key</span>
|
||||||
|
<input
|
||||||
|
name="clientApiKey"
|
||||||
|
type="password"
|
||||||
|
defaultValue=""
|
||||||
|
className="border py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
|
||||||
|
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700"
|
||||||
|
placeholder={meUser?.pterodactylClientApiKey ? '•••••••• (gesetzt)' : 'noch nicht gesetzt'}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
Leer lassen, um den bestehenden Key nicht zu überschreiben.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{/* ⬇️ nur noch der Speichern-Button (deine Button-Komponente) */}
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button type="submit" color="green" variant="solid" size="md" title="Speichern" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import { useSession } from 'next-auth/react'
|
|||||||
import Chart from '@/app/components/Chart'
|
import Chart from '@/app/components/Chart'
|
||||||
import { MatchStats } from '@/app/types/match'
|
import { MatchStats } from '@/app/types/match'
|
||||||
import Card from '../../../Card'
|
import Card from '../../../Card'
|
||||||
import UserClips from '../../../UserClips'
|
// import UserClips from '../../../UserClips'
|
||||||
|
|
||||||
type MatchStatsProps = {
|
type MatchStatsProps = {
|
||||||
stats: { matches: MatchStats[] }
|
stats: { matches: MatchStats[] }
|
||||||
|
|||||||
394
src/app/components/radar/StaticEffects.tsx
Normal file
394
src/app/components/radar/StaticEffects.tsx
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
// src/app/components/radar/StaticEffects.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type BombState = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
status: 'carried'|'dropped'|'planted'|'defusing'|'defused'|'unknown'
|
||||||
|
changedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Grenade = {
|
||||||
|
id: string
|
||||||
|
kind: 'smoke' | 'molotov' | 'incendiary' | 'he' | 'flash' | 'decoy' | 'unknown'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
radius?: number | null
|
||||||
|
expiresAt?: number | null
|
||||||
|
team?: 'T' | 'CT' | string | null
|
||||||
|
phase?: 'projectile' | 'effect' | 'exploded'
|
||||||
|
headingRad?: number | null
|
||||||
|
spawnedAt?: number | null
|
||||||
|
ownerId?: string | null
|
||||||
|
effectTimeSec?: number
|
||||||
|
lifeElapsedMs?: number
|
||||||
|
lifeLeftMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
||||||
|
|
||||||
|
type UIShape = {
|
||||||
|
nade: {
|
||||||
|
stroke: string
|
||||||
|
smokeFill: string
|
||||||
|
fireFill: string
|
||||||
|
heFill: string
|
||||||
|
flashFill: string
|
||||||
|
decoyFill: string
|
||||||
|
teamStrokeCT: string
|
||||||
|
teamStrokeT: string
|
||||||
|
minRadiusPx: number
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
bombStroke: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedRng(seed: string) {
|
||||||
|
let h = 2166136261 >>> 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
h ^= seed.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
h += 0x6D2B79F5; h = Math.imul(h ^ (h >>> 15), h | 1);
|
||||||
|
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||||||
|
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function StaticEffects({
|
||||||
|
grenades,
|
||||||
|
bomb,
|
||||||
|
worldToPx,
|
||||||
|
unitsToPx,
|
||||||
|
ui,
|
||||||
|
beepState,
|
||||||
|
}: {
|
||||||
|
grenades: Grenade[]
|
||||||
|
bomb: BombState | null
|
||||||
|
worldToPx: Mapper
|
||||||
|
unitsToPx: (u:number)=>number
|
||||||
|
ui: UIShape
|
||||||
|
beepState: { key: number; dur: number } | null
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const smokeNode = (g: Grenade) => {
|
||||||
|
const P = worldToPx(g.x, g.y)
|
||||||
|
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||||
|
|
||||||
|
// Lebenszeiten robust bestimmen
|
||||||
|
const DEFAULT_LIFE = 18_000
|
||||||
|
const leftMs = (typeof g.lifeLeftMs === 'number')
|
||||||
|
? Math.max(0, g.lifeLeftMs)
|
||||||
|
: (g.expiresAt ? Math.max(0, g.expiresAt - Date.now()) : null)
|
||||||
|
|
||||||
|
const elapsedMs = (typeof g.lifeElapsedMs === 'number')
|
||||||
|
? Math.max(0, g.lifeElapsedMs)
|
||||||
|
: (typeof g.effectTimeSec === 'number' ? Math.max(0, g.effectTimeSec * 1000) : null)
|
||||||
|
|
||||||
|
const totalMs = (leftMs != null && elapsedMs != null)
|
||||||
|
? Math.max(1500, leftMs + elapsedMs) // nie zu kurz
|
||||||
|
: DEFAULT_LIFE
|
||||||
|
|
||||||
|
const pRaw = (elapsedMs != null) ? (elapsedMs / totalMs)
|
||||||
|
: (leftMs != null) ? (1 - Math.min(1, leftMs / totalMs))
|
||||||
|
: 0
|
||||||
|
const p = Math.max(0, Math.min(1, pRaw)) // clamp
|
||||||
|
|
||||||
|
// Easing
|
||||||
|
const easeOutCubic = (t:number) => 1 - Math.pow(1 - t, 3)
|
||||||
|
const easeInQuad = (t:number) => t * t
|
||||||
|
|
||||||
|
// Phasen: früh reinzoomen, spät zusammenschrumpfen
|
||||||
|
const ZOOM_IN_MS = 600; // Dauer fürs Reinzoomen
|
||||||
|
const COLLAPSE_MS = 800; // Dauer fürs Zusammenfallen
|
||||||
|
const SCALE_IN_START = 0.68;
|
||||||
|
const SCALE_OUT_END = 0.65;
|
||||||
|
|
||||||
|
let scale = 1;
|
||||||
|
let phaseAlpha = 1;
|
||||||
|
|
||||||
|
if (elapsedMs != null && elapsedMs < ZOOM_IN_MS) {
|
||||||
|
// frühe Phase: innen -> außen
|
||||||
|
const t = Math.max(0, Math.min(1, elapsedMs / ZOOM_IN_MS));
|
||||||
|
const e = easeOutCubic(t);
|
||||||
|
scale = SCALE_IN_START + (1 - SCALE_IN_START) * e;
|
||||||
|
phaseAlpha = e;
|
||||||
|
} else if (leftMs != null && leftMs <= COLLAPSE_MS) {
|
||||||
|
// Endphase: außen -> innen
|
||||||
|
const t = Math.max(0, Math.min(1, 1 - (leftMs / COLLAPSE_MS)));
|
||||||
|
const e = easeInQuad(t);
|
||||||
|
scale = 1 - (1 - SCALE_OUT_END) * e;
|
||||||
|
phaseAlpha = 1 - e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bisherige sanfte Gesamt-Opacity (verbleibende Lebenszeit)
|
||||||
|
const lifeMs = totalMs
|
||||||
|
const fracLeft = leftMs == null ? 1 : Math.min(1, leftMs / lifeMs)
|
||||||
|
const baseOpacity = 0.75 + 0.25 * (1 - (1 - fracLeft)) // 0.75..1.0
|
||||||
|
const overallOpacity = baseOpacity * phaseAlpha
|
||||||
|
|
||||||
|
// Farbe (oder nimm ui.nade.smokeFill)
|
||||||
|
const fill = '#9bd4ff'
|
||||||
|
|
||||||
|
// kompaktere Form + runder oben (wie bei dir zuletzt)
|
||||||
|
const R = rPx * 0.78
|
||||||
|
const lobes = [
|
||||||
|
{ x: -R * 0.68, y: R * 0.14, r: R * 0.58, anim: '' },
|
||||||
|
{ x: 0, y: R * 0.06, r: R * 0.78, anim: '' },
|
||||||
|
{ x: R * 0.68, y: R * 0.14, r: R * 0.58, anim: '' },
|
||||||
|
|
||||||
|
{ x: -R * 0.36, y: -R * 0.24, r: R * 0.48, anim: 'A' },
|
||||||
|
{ x: 0, y: -R * 0.28, r: R * 0.52, anim: 'B' },
|
||||||
|
{ x: R * 0.36, y: -R * 0.24, r: R * 0.48, anim: 'C' },
|
||||||
|
|
||||||
|
{ x: 0, y: -R * 0.44, r: R * 0.36, anim: 'A' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={g.id}
|
||||||
|
transform={`translate(${P.x}, ${P.y}) scale(${scale})`} // <- Zoom über Zeit
|
||||||
|
opacity={overallOpacity}
|
||||||
|
style={{ transformBox: 'fill-box', transformOrigin: 'center' }}
|
||||||
|
>
|
||||||
|
{lobes.map((l, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={l.x}
|
||||||
|
cy={l.y}
|
||||||
|
r={l.r}
|
||||||
|
fill={fill}
|
||||||
|
style={
|
||||||
|
l.anim
|
||||||
|
? {
|
||||||
|
transformBox: 'fill-box',
|
||||||
|
transformOrigin: 'center',
|
||||||
|
animation:
|
||||||
|
l.anim === 'A' ? 'smokeLobeFloatA 2.8s ease-in-out infinite' :
|
||||||
|
l.anim === 'B' ? 'smokeLobeFloatB 3.1s ease-in-out infinite' :
|
||||||
|
'smokeLobeFloatC 2.9s ease-in-out infinite'
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const molotovNode = (g: Grenade) => {
|
||||||
|
const P = worldToPx(g.x, g.y)
|
||||||
|
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||||
|
// 1.0 = wie jetzt. 1.3..1.8 = größerer Radius
|
||||||
|
const FIRE_RADIUS_MULT = 2
|
||||||
|
|
||||||
|
const stroke = g.team === 'CT' ? ui.nade.teamStrokeCT : g.team === 'T' ? ui.nade.teamStrokeT : ui.nade.stroke
|
||||||
|
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
||||||
|
|
||||||
|
// Größen analog zum CodePen (Container ~10em breit, Rise ~10em)
|
||||||
|
const coverPx = rPx * FIRE_RADIUS_MULT // „Radius“ der Fläche
|
||||||
|
const W = Math.max(26, coverPx * 1.45) // Breite des Feuers
|
||||||
|
const H = W * 1.20 // Höhe der Säule
|
||||||
|
const RISE = H * (10/12) // Aufstiegsweg der Partikel
|
||||||
|
|
||||||
|
// Partikel (wie im Pen: 50 Stück, identische Größe)
|
||||||
|
const PARTS = Math.min(70, Math.round(50 * FIRE_RADIUS_MULT)) // Dichte mitwachsen lassen
|
||||||
|
const DUR_MS = 1000
|
||||||
|
const PART_SIZE = W * 0.50
|
||||||
|
const rnd = seedRng(`${g.id}-${g.spawnedAt ?? 0}`)
|
||||||
|
|
||||||
|
// Defs für Verlauf & Blur
|
||||||
|
const gradPartId = `molo-fire-grad-${g.id}`
|
||||||
|
const blurPartId = `molo-fire-blur-${g.id}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={g.id}>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id={gradPartId} cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="20%" stopColor="rgb(255,80,0)" stopOpacity="1" />
|
||||||
|
<stop offset="70%" stopColor="rgb(255,80,0)" stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
<filter id={blurPartId} x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
{/* entspricht blur(0.02em) */}
|
||||||
|
<feGaussianBlur stdDeviation={Math.max(0.6, W * 0.02)} />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Partikel exakt bei P, über die Breite verteilt */}
|
||||||
|
<g transform={`translate(${P.x}, ${P.y})`} style={{ ['--rise' as any]: `${Math.round(RISE)}px` }}>
|
||||||
|
{Array.from({ length: PARTS }).map((_, i) => {
|
||||||
|
// „left: calc((100% - partSize) * (i/parts))“
|
||||||
|
const leftX = (i / PARTS) * (W - PART_SIZE) - (W/2 - PART_SIZE/2)
|
||||||
|
return (
|
||||||
|
<g key={i} transform={`translate(${leftX}, 0)`}>
|
||||||
|
<circle
|
||||||
|
cx={0}
|
||||||
|
cy={0}
|
||||||
|
r={PART_SIZE / 2}
|
||||||
|
fill={`url(#${gradPartId})`}
|
||||||
|
filter={`url(#${blurPartId})`}
|
||||||
|
style={{
|
||||||
|
mixBlendMode: 'screen' as any,
|
||||||
|
animation: `molotovFireRise ${DUR_MS}ms ease-in infinite`,
|
||||||
|
animationDelay: `${Math.round(DUR_MS * rnd())}ms`,
|
||||||
|
transformBox: 'fill-box',
|
||||||
|
transformOrigin: 'center',
|
||||||
|
opacity: 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const decoyNode = (g: Grenade) => {
|
||||||
|
const P = worldToPx(g.x, g.y)
|
||||||
|
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||||
|
const stroke = g.team === 'CT' ? ui.nade.teamStrokeCT : g.team === 'T' ? ui.nade.teamStrokeT : ui.nade.stroke
|
||||||
|
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={g.id}
|
||||||
|
cx={P.x}
|
||||||
|
cy={P.y}
|
||||||
|
r={rPx}
|
||||||
|
fill={ui.nade.decoyFill}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={sw}
|
||||||
|
strokeDasharray="6,4"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flashNode = (g: Grenade) => {
|
||||||
|
const P = worldToPx(g.x, g.y)
|
||||||
|
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||||
|
const stroke = g.team === 'CT' ? ui.nade.teamStrokeCT : g.team === 'T' ? ui.nade.teamStrokeT : ui.nade.stroke
|
||||||
|
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={g.id}>
|
||||||
|
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
|
||||||
|
<circle cx={P.x} cy={P.y} r={Math.max(2, rPx*0.25)} fill={ui.nade.flashFill} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
|
||||||
|
<line x1={P.x-rPx*0.9} y1={P.y} x2={P.x+rPx*0.9} y2={P.y} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
|
||||||
|
<line x1={P.x} y1={P.y-rPx*0.9} x2={P.x} y2={P.y+rPx*0.9} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bombNode = (b: BombState) => {
|
||||||
|
const show = b.status === 'planted' || b.status === 'defusing' || b.status === 'defused'
|
||||||
|
if (!show) return null
|
||||||
|
const P = worldToPx(b.x, b.y)
|
||||||
|
const rBase = Math.max(10, unitsToPx(28))
|
||||||
|
const iconSize = Math.max(24, rBase * 1.8)
|
||||||
|
const isActive = b.status === 'planted' || b.status === 'defusing'
|
||||||
|
const isDefused = b.status === 'defused'
|
||||||
|
const iconColor = b.status === 'planted' ? '#ef4444' : (isDefused ? '#10b981' : '#e5e7eb')
|
||||||
|
const maskId = `bomb-mask-${b.changedAt}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`bomb-${b.changedAt}`}>
|
||||||
|
{isActive && beepState && (
|
||||||
|
<g key={`beep-${beepState.key}`}>
|
||||||
|
<circle
|
||||||
|
cx={P.x} cy={P.y} r={rBase}
|
||||||
|
fill="none"
|
||||||
|
stroke={isDefused ? '#10b981' : '#ef4444'}
|
||||||
|
strokeWidth={3}
|
||||||
|
style={{ transformBox: 'fill-box', transformOrigin: 'center', animation: `bombPing ${beepState.dur}ms linear 1` }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<circle cx={P.x} cy={P.y} r={rBase} fill="#111" opacity="0.15" />
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<mask id={maskId}>
|
||||||
|
<image
|
||||||
|
href="/assets/img/icons/ui/bomb_c4.svg"
|
||||||
|
x={P.x - iconSize/2}
|
||||||
|
y={P.y - iconSize/2}
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={P.x - iconSize/2}
|
||||||
|
y={P.y - iconSize/2}
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
fill={iconColor}
|
||||||
|
mask={`url(#${maskId})`}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nur die gewünschten statischen Effekte zeichnen
|
||||||
|
const nodes = grenades
|
||||||
|
.filter(g => g.phase === 'effect' && (g.kind === 'smoke' || g.kind === 'molotov' || g.kind === 'incendiary' || g.kind === 'decoy' || g.kind === 'flash'))
|
||||||
|
.map(g => {
|
||||||
|
const P = worldToPx(g.x, g.y)
|
||||||
|
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
||||||
|
if (g.kind === 'smoke') return smokeNode(g)
|
||||||
|
if (g.kind === 'molotov' || g.kind === 'incendiary') return molotovNode(g)
|
||||||
|
if (g.kind === 'decoy') return decoyNode(g)
|
||||||
|
if (g.kind === 'flash') return flashNode(g)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* statische Grenade-Effekte */}
|
||||||
|
{nodes}
|
||||||
|
{/* Bombe */}
|
||||||
|
{bomb ? bombNode(bomb) : null}
|
||||||
|
|
||||||
|
{/* lokale Keyframes für die Flammen/Glut */}
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes smokeLobeFloatA {
|
||||||
|
0% { transform: translateY(0px) scale(1); }
|
||||||
|
50% { transform: translateY(-2px) scale(1.02); }
|
||||||
|
100% { transform: translateY(0px) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes smokeLobeFloatB {
|
||||||
|
0% { transform: translateY(-1px) scale(1.01); }
|
||||||
|
50% { transform: translateY(-3px) scale(1.03); }
|
||||||
|
100% { transform: translateY(-1px) scale(1.01); }
|
||||||
|
}
|
||||||
|
@keyframes smokeLobeFloatC {
|
||||||
|
0% { transform: translateY(0px) scale(1); }
|
||||||
|
50% { transform: translateY(-2px) scale(1.02); }
|
||||||
|
100% { transform: translateY(0px) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes molotovFireRise {
|
||||||
|
0% { opacity: 0; transform: translateY(0) scale(1); }
|
||||||
|
25% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translateY(calc(var(--rise, 80px) * -1)) scale(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/app/lib/connectHref.ts
Normal file
8
src/app/lib/connectHref.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// helper, z.B. /src/app/lib/connectHref.ts
|
||||||
|
export function buildConnectHref(serverIp: string, password?: string | null, port = 27015) {
|
||||||
|
const pass = (password ?? '').trim()
|
||||||
|
// Format wie bisher in deinem Overlay verwendet:
|
||||||
|
return pass
|
||||||
|
? `steam://connect/${serverIp}:${port}/${pass}`
|
||||||
|
: `steam://connect/${serverIp}:${port}`
|
||||||
|
}
|
||||||
29
src/app/lib/useMatchRosterStore.ts
Normal file
29
src/app/lib/useMatchRosterStore.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// /app/lib/useMatchRosterStore.ts
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
type RosterState = {
|
||||||
|
roster: Set<string>
|
||||||
|
setRoster: (ids: Iterable<string>) => void
|
||||||
|
clearRoster: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMatchRosterStore = create<RosterState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
roster: new Set(),
|
||||||
|
setRoster: (ids) => set({ roster: new Set(ids) }),
|
||||||
|
clearRoster: () => set({ roster: new Set() }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'last-export-roster', // landet in localStorage
|
||||||
|
partialize: (s) => ({ roster: Array.from(s.roster) as any }),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
// Arrays zurück in Set umwandeln
|
||||||
|
if (state && Array.isArray((state as any).roster)) {
|
||||||
|
state.roster = new Set((state as any).roster)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -1,40 +1,29 @@
|
|||||||
// /src/app/lib/useReadyOverlayStore.ts
|
// /src/app/lib/useReadyOverlayStore.ts
|
||||||
|
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
type ReadyOverlayData = {
|
export type ReadyOverlayData = {
|
||||||
matchId: string
|
matchId: string
|
||||||
mapLabel: string
|
mapLabel: string
|
||||||
mapBg: string
|
mapBg: string
|
||||||
nextHref?: string // Zielroute nach "ACCEPT"
|
nextHref?: string
|
||||||
|
connectHref?: string // ⬅️ neu
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type ReadyOverlayState = {
|
||||||
open: boolean
|
open: boolean
|
||||||
data: ReadyOverlayData | null
|
data: ReadyOverlayData | null
|
||||||
showAt?: number | null
|
show: (data: ReadyOverlayData) => void
|
||||||
showWithDelay: (data: ReadyOverlayData, delayMs: number) => void
|
showWithDelay: (data: ReadyOverlayData, delayMs?: number) => void
|
||||||
hide: () => void
|
hide: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadyOverlayStore = create<State>((set) => ({
|
export const useReadyOverlayStore = create<ReadyOverlayState>((set) => ({
|
||||||
open: false,
|
open: false,
|
||||||
data: null,
|
data: null,
|
||||||
showAt: null,
|
show: (data) => set({ open: true, data }),
|
||||||
showWithDelay: (data, delayMs) => {
|
showWithDelay: (data, delayMs = 0) => {
|
||||||
const showAt = Date.now() + Math.max(0, delayMs)
|
if (delayMs <= 0) return set({ open: true, data })
|
||||||
set({ data, showAt })
|
setTimeout(() => set({ open: true, data }), delayMs)
|
||||||
const step = () => {
|
|
||||||
const t = Date.now()
|
|
||||||
if (t >= showAt) {
|
|
||||||
set({ open: true, showAt: null })
|
|
||||||
} else {
|
|
||||||
requestAnimationFrame(step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestAnimationFrame(step)
|
|
||||||
},
|
},
|
||||||
hide: () => set({ open: false, data: null, showAt: null }),
|
hide: () => set({ open: false, data: null }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -1,13 +1,26 @@
|
|||||||
// /src/app/lib/useTelemetryStore.ts
|
// /app/lib/useTelemetryStore.ts
|
||||||
'use client'
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
type State = {
|
type TelemetryState = {
|
||||||
mapKey: string | null
|
mapKey: string | null
|
||||||
setMapKey: (k: string | null) => void
|
setMapKey: (k: string|null) => void
|
||||||
|
|
||||||
|
phase: 'unknown'|'warmup'|'freezetime'|'live'|'bomb'|'over'
|
||||||
|
setPhase: (p: TelemetryState['phase']) => void
|
||||||
|
|
||||||
|
rosterSteamIds: Set<string>
|
||||||
|
setRosterSteamIds: (ids: string[]) => void
|
||||||
|
clearRoster: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTelemetryStore = create<State>((set) => ({
|
export const useTelemetryStore = create<TelemetryState>((set) => ({
|
||||||
mapKey: null,
|
mapKey: null,
|
||||||
setMapKey: (k) => set({ mapKey: k }),
|
setMapKey: (k) => set({ mapKey: k }),
|
||||||
|
|
||||||
|
phase: 'unknown',
|
||||||
|
setPhase: (p) => set({ phase: p }),
|
||||||
|
|
||||||
|
rosterSteamIds: new Set<string>(),
|
||||||
|
setRosterSteamIds: (ids) => set({ rosterSteamIds: new Set(ids ?? []) }),
|
||||||
|
clearRoster: () => set({ rosterSteamIds: new Set() }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
|
|||||||
import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
|
import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
|
||||||
import StatusDot from '../components/StatusDot'
|
import StatusDot from '../components/StatusDot'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
|
import StaticEffects from '../components/radar/StaticEffects'
|
||||||
|
|
||||||
|
|
||||||
/* ───────── UI config ───────── */
|
/* ───────── UI config ───────── */
|
||||||
const UI = {
|
const UI = {
|
||||||
@ -776,7 +778,9 @@ export default function LiveRadar() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Effekte dürfen positionsbasiert sein
|
// Effekte dürfen positionsbasiert sein
|
||||||
id = `${kind}#${Math.round(x)}:${Math.round(y)}:${Math.round(z)}:${phase}`;
|
const Q = 64; // 64 Welt-Units Raster; 48..96 funktioniert auch gut
|
||||||
|
const qx = Math.round(x / Q), qy = Math.round(y / Q);
|
||||||
|
id = `${kind}@${qx}:${qy}:${ownerId ?? 'u'}`; // stabil trotz kleinem Drift
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smoke-spezifische Zusatzwerte (mit 19s Default)
|
// Smoke-spezifische Zusatzwerte (mit 19s Default)
|
||||||
@ -941,12 +945,13 @@ export default function LiveRadar() {
|
|||||||
...prev,
|
...prev,
|
||||||
...it,
|
...it,
|
||||||
spawnedAt: prev?.spawnedAt ?? it.spawnedAt ?? now,
|
spawnedAt: prev?.spawnedAt ?? it.spawnedAt ?? now,
|
||||||
headingRad: (it.headingRad ?? prev?.headingRad ?? null),
|
|
||||||
_lastSeen: now,
|
_lastSeen: now,
|
||||||
// _cacheKey kommt von normalizeGrenades (nur Projektile)
|
|
||||||
_cacheKey: (it as any)._cacheKey ?? (prev as any)?._cacheKey
|
|
||||||
};
|
};
|
||||||
next.set(it.id, merged);
|
// Effekt-/Explosions-Lebensdauer nie „verlängern“
|
||||||
|
if (it.phase === 'effect' || it.phase === 'exploded') {
|
||||||
|
merged.expiresAt = (prev?.expiresAt ?? it.expiresAt) ?? null;
|
||||||
|
}
|
||||||
|
next.set(merged.id, merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup: Effekte nach Ablauf; Projektile nach Schonfrist (+ Cache leeren)
|
// Cleanup: Effekte nach Ablauf; Projektile nach Schonfrist (+ Cache leeren)
|
||||||
@ -956,11 +961,14 @@ export default function LiveRadar() {
|
|||||||
if (nade.phase === 'effect' || nade.phase === 'exploded') {
|
if (nade.phase === 'effect' || nade.phase === 'exploded') {
|
||||||
const left = (typeof nade.lifeLeftMs === 'number')
|
const left = (typeof nade.lifeLeftMs === 'number')
|
||||||
? nade.lifeLeftMs
|
? nade.lifeLeftMs
|
||||||
: (typeof nade.expiresAt === 'number' ? (nade.expiresAt - Date.now()) : null);
|
: (typeof nade.expiresAt === 'number' ? (nade.expiresAt - now) : null);
|
||||||
if (left != null && left <= 0) {
|
|
||||||
|
// Fallback: harte TTL von 8s, falls left==null
|
||||||
|
const age = now - (nade.spawnedAt ?? now);
|
||||||
|
if ((left != null && left <= 0) || (left == null && age > 8000)) {
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nade.phase === 'projectile') {
|
if (nade.phase === 'projectile') {
|
||||||
@ -1433,34 +1441,6 @@ export default function LiveRadar() {
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
>
|
>
|
||||||
<defs>
|
|
||||||
<radialGradient id="smokeRadial" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stopColor="#B9B9B9" stopOpacity="0.70" />
|
|
||||||
<stop offset="65%" stopColor="#A0A0A0" stopOpacity="0.35" />
|
|
||||||
<stop offset="100%" stopColor="#A0A0A0" stopOpacity="0.00" />
|
|
||||||
</radialGradient>
|
|
||||||
|
|
||||||
<linearGradient id="flameGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
||||||
<stop offset="0%" stopColor="#fff59d"/>
|
|
||||||
<stop offset="45%" stopColor="#ffd54f"/>
|
|
||||||
<stop offset="100%" stopColor="#ff7043"/>
|
|
||||||
</linearGradient>
|
|
||||||
|
|
||||||
<symbol id="flameIcon" viewBox="0 0 64 64">
|
|
||||||
{/* äußere Flamme */}
|
|
||||||
<path
|
|
||||||
d="M32 4c6 11-4 14 2 23 3 4 10 7 10 16 0 10-8 17-18 17S8 53 8 43c0-8 5-13 9-17 6-6 8-10 15-22z"
|
|
||||||
fill="url(#flameGrad)"
|
|
||||||
/>
|
|
||||||
{/* innerer, heller Kern */}
|
|
||||||
<path
|
|
||||||
d="M33 20c3 6-2 8 1 12 2 2 6 3 6 8 0 5-4 9-10 9s-10-4-10-9c0-4 3-7 5-9 3-3 4-5 8-11z"
|
|
||||||
fill="#ffffff66"
|
|
||||||
/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* Trails */}
|
{/* Trails */}
|
||||||
{trails.map(tr => {
|
{trails.map(tr => {
|
||||||
const pts = tr.pts.map(p => {
|
const pts = tr.pts.map(p => {
|
||||||
@ -1481,190 +1461,65 @@ export default function LiveRadar() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Grenades: Projectiles + Effekte */}
|
{/* 🔽 NEU: statische Effekte (Smoke/Molotov/Incendiary/Decoy/Flash) + Bombe */}
|
||||||
{grenades
|
<StaticEffects
|
||||||
//.filter(shouldShowGrenade)
|
grenades={grenades}
|
||||||
.map((g) => {
|
bomb={bomb}
|
||||||
const P = worldToPx(g.x, g.y)
|
worldToPx={worldToPx}
|
||||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
unitsToPx={unitsToPx}
|
||||||
|
ui={{ nade: UI.nade, player: { bombStroke: UI.player.bombStroke } }}
|
||||||
|
beepState={beepState}
|
||||||
|
/>
|
||||||
|
|
||||||
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
{/* Grenades: nur Projectiles + HE-Explosionen (statische Effekte macht <StaticEffects />) */}
|
||||||
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
|
{grenades.map((g) => {
|
||||||
: g.team === 'T' ? UI.nade.teamStrokeT
|
const P = worldToPx(g.x, g.y)
|
||||||
: UI.nade.stroke
|
|
||||||
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
|
||||||
|
|
||||||
// 1) Projektil-Icon
|
|
||||||
if (g.phase === 'projectile') {
|
|
||||||
const size = Math.max(18, 22); // fix/klein, statt radius-basiert (optional)
|
|
||||||
const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown;
|
|
||||||
const rotDeg = Number.isFinite(g.headingRad as number) ? (g.headingRad! * 180 / Math.PI) : 0;
|
|
||||||
return (
|
|
||||||
<g key={`nade-proj-${g.id}`} transform={`rotate(${rotDeg} ${P.x} ${P.y})`}>
|
|
||||||
<image
|
|
||||||
href={href}
|
|
||||||
x={P.x - size/2}
|
|
||||||
y={P.y - size/2}
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 2) HE-Explosion
|
|
||||||
if (g.kind === 'he' && g.phase === 'exploded') {
|
|
||||||
const base = Math.max(18, unitsToPx(22))
|
|
||||||
const dur = 450
|
|
||||||
const key = `he-burst-${g.id}-${g.spawnedAt}`
|
|
||||||
return (
|
|
||||||
<g key={key}>
|
|
||||||
<circle
|
|
||||||
cx={P.x} cy={P.y} r={base}
|
|
||||||
fill="none" stroke={UI.nade.heFill} strokeWidth={3}
|
|
||||||
style={{ transformBox:'fill-box', transformOrigin:'center', animation:`heExplode ${dur}ms ease-out 1` }}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Statische Effekte
|
|
||||||
if (g.kind === 'smoke') {
|
|
||||||
const lifeMs = 18_000
|
|
||||||
const left = (typeof g.lifeLeftMs === 'number')
|
|
||||||
? Math.max(0, g.lifeLeftMs)
|
|
||||||
: (g.expiresAt ? Math.max(0, g.expiresAt - Date.now()) : null)
|
|
||||||
const frac = left == null ? 1 : Math.min(1, left / lifeMs)
|
|
||||||
|
|
||||||
// leichte Aufhellung/Abdunklung via Opacity-Multiplikator
|
|
||||||
const opacity = 0.35 + 0.45 * frac // 0.35 .. 0.80
|
|
||||||
|
|
||||||
return (
|
|
||||||
<circle
|
|
||||||
key={g.id}
|
|
||||||
cx={P.x}
|
|
||||||
cy={P.y}
|
|
||||||
r={rPx}
|
|
||||||
fill={UI.nade.smokeFill}
|
|
||||||
fillOpacity={opacity}
|
|
||||||
stroke={stroke}
|
|
||||||
strokeWidth={sw}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (g.kind === 'molotov' || g.kind === 'incendiary') {
|
|
||||||
const W = Math.max(28, rPx * 1.4);
|
|
||||||
const H = W * 1.25;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={g.id}>
|
|
||||||
{/* optionaler Team-Ring */}
|
|
||||||
<circle
|
|
||||||
cx={P.x}
|
|
||||||
cy={P.y}
|
|
||||||
r={rPx}
|
|
||||||
fill="none"
|
|
||||||
stroke={stroke}
|
|
||||||
strokeWidth={Math.max(1, sw * 0.8)}
|
|
||||||
strokeDasharray="6,5"
|
|
||||||
opacity="0.6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* WICHTIG: äußere Gruppe = nur Translate */}
|
|
||||||
<g transform={`translate(${P.x}, ${P.y})`}>
|
|
||||||
{/* innere Gruppe = nur Animation */}
|
|
||||||
<g className="flame-anim">
|
|
||||||
<use
|
|
||||||
href="#flameIcon"
|
|
||||||
x={-W / 2}
|
|
||||||
y={-H / 2}
|
|
||||||
width={W}
|
|
||||||
height={H}
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
opacity="0.95"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (g.kind === 'decoy') {
|
|
||||||
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
|
|
||||||
}
|
|
||||||
if (g.kind === 'flash') {
|
|
||||||
return (
|
|
||||||
<g key={g.id}>
|
|
||||||
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
|
|
||||||
<circle cx={P.x} cy={P.y} r={Math.max(2, rPx*0.25)} fill={UI.nade.flashFill} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
|
|
||||||
<line x1={P.x-rPx*0.9} y1={P.y} x2={P.x+rPx*0.9} y2={P.y} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
|
|
||||||
<line x1={P.x} y1={P.y-rPx*0.9} x2={P.x} y2={P.y+rPx*0.9} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill="#999" stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Bombe */}
|
|
||||||
{bomb && (() => {
|
|
||||||
const showBomb = bomb.status === 'planted' || bomb.status === 'defusing' || bomb.status === 'defused'
|
|
||||||
if (!showBomb) return null
|
|
||||||
const P = worldToPx(bomb.x, bomb.y)
|
|
||||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
||||||
|
|
||||||
const rBase = Math.max(10, unitsToPx(28))
|
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||||
const iconSize = Math.max(24, rBase * 1.8)
|
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
|
||||||
const isActive = bomb.status === 'planted' || bomb.status === 'defusing'
|
: g.team === 'T' ? UI.nade.teamStrokeT
|
||||||
const isDefused = bomb.status === 'defused'
|
: UI.nade.stroke
|
||||||
const iconColor = bomb.status === 'planted' ? '#ef4444' : (isDefused ? '#10b981' : '#e5e7eb')
|
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
||||||
const maskId = `bomb-mask-${bomb.changedAt}`
|
|
||||||
|
|
||||||
return (
|
// 1) Projektil-Icon
|
||||||
<g key={`bomb-${bomb.changedAt}`}>
|
if (g.phase === 'projectile') {
|
||||||
{isActive && beepState && (
|
const size = Math.max(18, 22)
|
||||||
<g key={`beep-${beepState.key}`}>
|
const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown
|
||||||
<circle
|
const rotDeg = Number.isFinite(g.headingRad as number) ? (g.headingRad! * 180 / Math.PI) : 0
|
||||||
cx={P.x} cy={P.y} r={rBase}
|
return (
|
||||||
fill="none"
|
<g key={`nade-proj-${g.id}`} transform={`rotate(${rotDeg} ${P.x} ${P.y})`}>
|
||||||
stroke={isDefused ? '#10b981' : '#ef4444'}
|
<image
|
||||||
strokeWidth={3}
|
href={href}
|
||||||
style={{ transformBox: 'fill-box', transformOrigin: 'center', animation: `bombPing ${beepState.dur}ms linear 1` }}
|
x={P.x - size/2}
|
||||||
/>
|
y={P.y - size/2}
|
||||||
</g>
|
width={size}
|
||||||
)}
|
height={size}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<circle cx={P.x} cy={P.y} r={rBase} fill="#111" opacity="0.15" />
|
// 2) HE-Explosion
|
||||||
|
if (g.kind === 'he' && g.phase === 'exploded') {
|
||||||
|
const base = Math.max(18, unitsToPx(22))
|
||||||
|
const dur = 450
|
||||||
|
const key = `he-burst-${g.id}-${g.spawnedAt}`
|
||||||
|
return (
|
||||||
|
<g key={key}>
|
||||||
|
<circle
|
||||||
|
cx={P.x} cy={P.y} r={base}
|
||||||
|
fill="none" stroke={UI.nade.heFill} strokeWidth={3}
|
||||||
|
style={{ transformBox:'fill-box', transformOrigin:'center', animation:`heExplode ${dur}ms ease-out 1` }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<defs>
|
// statische Effekte (smoke/molotov/incendiary/decoy/flash) werden NICHT mehr hier gezeichnet
|
||||||
<mask id={maskId}>
|
return null
|
||||||
<image
|
})}
|
||||||
href="/assets/img/icons/ui/bomb_c4.svg"
|
|
||||||
xlinkHref="/assets/img/icons/ui/bomb_c4.svg"
|
|
||||||
x={P.x - iconSize/2}
|
|
||||||
y={P.y - iconSize/2}
|
|
||||||
width={iconSize}
|
|
||||||
height={iconSize}
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect
|
|
||||||
x={P.x - iconSize/2}
|
|
||||||
y={P.y - iconSize/2}
|
|
||||||
width={iconSize}
|
|
||||||
height={iconSize}
|
|
||||||
fill={iconColor}
|
|
||||||
mask={`url(#${maskId})`}
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Spieler */}
|
{/* Spieler */}
|
||||||
{players
|
{players
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
module.exports = { ...require('.') }
|
module.exports = { ...require('#main-entry-point') }
|
||||||
File diff suppressed because one or more lines are too long
@ -20,12 +20,12 @@ exports.Prisma = Prisma
|
|||||||
exports.$Enums = {}
|
exports.$Enums = {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prisma Client JS version: 6.15.0
|
* Prisma Client JS version: 6.16.1
|
||||||
* Query Engine version: 85179d7826409ee107a6ba334b5e305ae3fba9fb
|
* Query Engine version: 1c57fdcd7e44b29b9313256c76699e91c3ac3c43
|
||||||
*/
|
*/
|
||||||
Prisma.prismaVersion = {
|
Prisma.prismaVersion = {
|
||||||
client: "6.15.0",
|
client: "6.16.1",
|
||||||
engine: "85179d7826409ee107a6ba334b5e305ae3fba9fb"
|
engine: "1c57fdcd7e44b29b9313256c76699e91c3ac3c43"
|
||||||
}
|
}
|
||||||
|
|
||||||
Prisma.PrismaClientKnownRequestError = () => {
|
Prisma.PrismaClientKnownRequestError = () => {
|
||||||
@ -133,7 +133,8 @@ exports.Prisma.UserScalarFieldEnum = {
|
|||||||
lastKnownShareCodeDate: 'lastKnownShareCodeDate',
|
lastKnownShareCodeDate: 'lastKnownShareCodeDate',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
status: 'status',
|
status: 'status',
|
||||||
lastActiveAt: 'lastActiveAt'
|
lastActiveAt: 'lastActiveAt',
|
||||||
|
pterodactylClientApiKey: 'pterodactylClientApiKey'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.TeamScalarFieldEnum = {
|
exports.Prisma.TeamScalarFieldEnum = {
|
||||||
@ -310,6 +311,16 @@ exports.Prisma.MatchReadyScalarFieldEnum = {
|
|||||||
acceptedAt: 'acceptedAt'
|
acceptedAt: 'acceptedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.ServerConfigScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
serverIp: 'serverIp',
|
||||||
|
serverPassword: 'serverPassword',
|
||||||
|
pterodactylServerId: 'pterodactylServerId',
|
||||||
|
pterodactylServerApiKey: 'pterodactylServerApiKey',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.SortOrder = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@ -369,7 +380,8 @@ exports.Prisma.ModelName = {
|
|||||||
ServerRequest: 'ServerRequest',
|
ServerRequest: 'ServerRequest',
|
||||||
MapVote: 'MapVote',
|
MapVote: 'MapVote',
|
||||||
MapVoteStep: 'MapVoteStep',
|
MapVoteStep: 'MapVoteStep',
|
||||||
MatchReady: 'MatchReady'
|
MatchReady: 'MatchReady',
|
||||||
|
ServerConfig: 'ServerConfig'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
1393
src/generated/prisma/index.d.ts
vendored
1393
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-2e5c1f8f0e3dd7b271c7986ac6cb6fd933eac0338be7033c8e86d6bde46642fd",
|
"name": "prisma-client-85c440bbfe4ddbdbf4749495c6ef753c2d4a73eb44baae0e95774ed8e7b86d85",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "default.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./client": {
|
"./client": {
|
||||||
"require": {
|
"require": {
|
||||||
@ -125,6 +125,12 @@
|
|||||||
"import": "./runtime/react-native.js",
|
"import": "./runtime/react-native.js",
|
||||||
"default": "./runtime/react-native.js"
|
"default": "./runtime/react-native.js"
|
||||||
},
|
},
|
||||||
|
"./runtime/index-browser": {
|
||||||
|
"types": "./runtime/index-browser.d.ts",
|
||||||
|
"require": "./runtime/index-browser.js",
|
||||||
|
"import": "./runtime/index-browser.mjs",
|
||||||
|
"default": "./runtime/index-browser.mjs"
|
||||||
|
},
|
||||||
"./generator-build": {
|
"./generator-build": {
|
||||||
"require": "./generator-build/index.js",
|
"require": "./generator-build/index.js",
|
||||||
"import": "./generator-build/index.js",
|
"import": "./generator-build/index.js",
|
||||||
@ -145,6 +151,33 @@
|
|||||||
},
|
},
|
||||||
"./*": "./*"
|
"./*": "./*"
|
||||||
},
|
},
|
||||||
"version": "6.15.0",
|
"version": "6.16.1",
|
||||||
"sideEffects": false
|
"sideEffects": false,
|
||||||
|
"imports": {
|
||||||
|
"#wasm-engine-loader": {
|
||||||
|
"edge-light": "./wasm-edge-light-loader.mjs",
|
||||||
|
"workerd": "./wasm-worker-loader.mjs",
|
||||||
|
"worker": "./wasm-worker-loader.mjs",
|
||||||
|
"default": "./wasm-worker-loader.mjs"
|
||||||
|
},
|
||||||
|
"#main-entry-point": {
|
||||||
|
"require": {
|
||||||
|
"node": "./index.js",
|
||||||
|
"edge-light": "./wasm.js",
|
||||||
|
"workerd": "./wasm.js",
|
||||||
|
"worker": "./wasm.js",
|
||||||
|
"browser": "./index-browser.js",
|
||||||
|
"default": "./index.js"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"node": "./index.js",
|
||||||
|
"edge-light": "./wasm.js",
|
||||||
|
"workerd": "./wasm.js",
|
||||||
|
"worker": "./wasm.js",
|
||||||
|
"browser": "./index-browser.js",
|
||||||
|
"default": "./index.js"
|
||||||
|
},
|
||||||
|
"default": "./index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp17192
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp17192
Normal file
Binary file not shown.
2
src/generated/prisma/query_engine_bg.js
Normal file
2
src/generated/prisma/query_engine_bg.js
Normal file
File diff suppressed because one or more lines are too long
BIN
src/generated/prisma/query_engine_bg.wasm
Normal file
BIN
src/generated/prisma/query_engine_bg.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
56
src/generated/prisma/runtime/react-native.js
vendored
56
src/generated/prisma/runtime/react-native.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -50,6 +50,8 @@ model User {
|
|||||||
lastActiveAt DateTime? // optional: wann zuletzt aktiv
|
lastActiveAt DateTime? // optional: wann zuletzt aktiv
|
||||||
|
|
||||||
readyAcceptances MatchReady[] @relation("MatchReadyUser")
|
readyAcceptances MatchReady[] @relation("MatchReadyUser")
|
||||||
|
|
||||||
|
pterodactylClientApiKey String?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserStatus {
|
enum UserStatus {
|
||||||
@ -370,3 +372,17 @@ model MatchReady {
|
|||||||
@@id([matchId, steamId])
|
@@id([matchId, steamId])
|
||||||
@@index([steamId])
|
@@index([steamId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 🛠️ Server-Konfiguration & Pterodactyl
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
model ServerConfig {
|
||||||
|
id String @id
|
||||||
|
serverIp String
|
||||||
|
serverPassword String? // ⬅️ neu
|
||||||
|
pterodactylServerId String
|
||||||
|
pterodactylServerApiKey String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
4
src/generated/prisma/wasm-edge-light-loader.mjs
Normal file
4
src/generated/prisma/wasm-edge-light-loader.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||||
|
/* eslint-disable */
|
||||||
|
export default import('./query_engine_bg.wasm?module')
|
||||||
4
src/generated/prisma/wasm-worker-loader.mjs
Normal file
4
src/generated/prisma/wasm-worker-loader.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||||
|
/* eslint-disable */
|
||||||
|
export default import('./query_engine_bg.wasm')
|
||||||
2
src/generated/prisma/wasm.d.ts
vendored
2
src/generated/prisma/wasm.d.ts
vendored
@ -1 +1 @@
|
|||||||
export * from "./index"
|
export * from "./default"
|
||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user