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
|
||||
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
|
||||
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_USER=army.37a11489
|
||||
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",
|
||||
"@preline/dropdown": "^3.0.1",
|
||||
"@preline/tooltip": "^3.0.0",
|
||||
"@prisma/client": "^6.15.0",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"chart.js": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
"csgo-sharecode": "^3.1.2",
|
||||
@ -61,7 +61,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"prisma": "^6.15.0",
|
||||
"prisma": "^6.16.1",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.4",
|
||||
@ -1599,9 +1599,9 @@
|
||||
"license": "Licensed under MIT and Preline UI Fair Use License"
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.15.0.tgz",
|
||||
"integrity": "sha512-wR2LXUbOH4cL/WToatI/Y2c7uzni76oNFND7+23ypLllBmIS8e3ZHhO+nud9iXSXKFt1SoM3fTZvHawg63emZw==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.1.tgz",
|
||||
"integrity": "sha512-QaBCOY29lLAxEFFJgBPyW3WInCW52fJeQTmWx/h6YsP5u0bwuqP51aP0uhqFvhK9DaZPwvai/M4tSDYLVE9vRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@ -1621,9 +1621,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.15.0.tgz",
|
||||
"integrity": "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.1.tgz",
|
||||
"integrity": "sha512-sz3uxRPNL62QrJ0EYiujCFkIGZ3hg+9hgC1Ae1HjoYuj0BxCqHua4JNijYvYCrh9LlofZDZcRBX3tHBfLvAngA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@ -1634,53 +1634,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.15.0.tgz",
|
||||
"integrity": "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.1.tgz",
|
||||
"integrity": "sha512-RWv/VisW5vJE4cDRTuAHeVedtGoItXTnhuLHsSlJ9202QKz60uiXWywBlVcqXVq8bFeIZoCoWH+R1duZJPwqLw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.15.0.tgz",
|
||||
"integrity": "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.1.tgz",
|
||||
"integrity": "sha512-EOnEM5HlosPudBqbI+jipmaW/vQEaF0bKBo4gVkGabasINHR6RpC6h44fKZEqx4GD8CvH+einD2+b49DQrwrAg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.15.0",
|
||||
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
||||
"@prisma/fetch-engine": "6.15.0",
|
||||
"@prisma/get-platform": "6.15.0"
|
||||
"@prisma/debug": "6.16.1",
|
||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"@prisma/fetch-engine": "6.16.1",
|
||||
"@prisma/get-platform": "6.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb.tgz",
|
||||
"integrity": "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg==",
|
||||
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
|
||||
"integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.15.0.tgz",
|
||||
"integrity": "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.1.tgz",
|
||||
"integrity": "sha512-fl/PKQ8da5YTayw86WD3O9OmKJEM43gD3vANy2hS5S1CnfW2oPXk+Q03+gUWqcKK306QqhjjIHRFuTZ31WaosQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.15.0",
|
||||
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
|
||||
"@prisma/get-platform": "6.15.0"
|
||||
"@prisma/debug": "6.16.1",
|
||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"@prisma/get-platform": "6.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz",
|
||||
"integrity": "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.1.tgz",
|
||||
"integrity": "sha512-kUfg4vagBG7dnaGRcGd1c0ytQFcDj2SUABiuveIpL3bthFdTLI6PJeLEia6Q8Dgh+WhPdo0N2q0Fzjk63XTyaA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.15.0"
|
||||
"@prisma/debug": "6.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
@ -6781,15 +6781,15 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.15.0.tgz",
|
||||
"integrity": "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.1.tgz",
|
||||
"integrity": "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.15.0",
|
||||
"@prisma/engines": "6.15.0"
|
||||
"@prisma/config": "6.16.1",
|
||||
"@prisma/engines": "6.16.1"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@preline/dropdown": "^3.0.1",
|
||||
"@preline/tooltip": "^3.0.0",
|
||||
"@prisma/client": "^6.15.0",
|
||||
"@prisma/client": "^6.16.1",
|
||||
"chart.js": "^4.5.0",
|
||||
"clsx": "^2.1.1",
|
||||
"csgo-sharecode": "^3.1.2",
|
||||
@ -65,7 +65,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"prisma": "^6.15.0",
|
||||
"prisma": "^6.16.1",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.4",
|
||||
|
||||
@ -1,372 +1,388 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
//
|
||||
// ──────────────────────────────────────────────
|
||||
// 🧑 Benutzer, Teams & Verwaltung
|
||||
// ──────────────────────────────────────────────
|
||||
//
|
||||
|
||||
model User {
|
||||
steamId String @id
|
||||
name String?
|
||||
avatar String?
|
||||
location String?
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
teamId String?
|
||||
team Team? @relation("UserTeam", fields: [teamId], references: [id])
|
||||
ledTeam Team? @relation("TeamLeader")
|
||||
|
||||
matchesAsTeamA Match[] @relation("TeamAPlayers")
|
||||
matchesAsTeamB Match[] @relation("TeamBPlayers")
|
||||
|
||||
premierRank Int?
|
||||
authCode String?
|
||||
lastKnownShareCode String?
|
||||
lastKnownShareCodeDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
invites TeamInvite[] @relation("UserInvitations")
|
||||
notifications Notification[]
|
||||
matchPlayers MatchPlayer[]
|
||||
serverRequests ServerRequest[] @relation("MatchRequests")
|
||||
rankHistory RankHistory[] @relation("UserRankHistory")
|
||||
demoFiles DemoFile[]
|
||||
|
||||
createdSchedules Schedule[] @relation("CreatedSchedules")
|
||||
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
|
||||
|
||||
mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
|
||||
|
||||
status UserStatus @default(offline) // 👈 neu
|
||||
lastActiveAt DateTime? // optional: wann zuletzt aktiv
|
||||
|
||||
readyAcceptances MatchReady[] @relation("MatchReadyUser")
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
online
|
||||
away
|
||||
offline
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
logo String?
|
||||
leaderId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
activePlayers String[]
|
||||
inactivePlayers String[]
|
||||
|
||||
leader User? @relation("TeamLeader", fields: [leaderId], references: [steamId])
|
||||
members User[] @relation("UserTeam")
|
||||
invites TeamInvite[]
|
||||
matchPlayers MatchPlayer[]
|
||||
|
||||
matchesAsTeamA Match[] @relation("MatchTeamA")
|
||||
matchesAsTeamB Match[] @relation("MatchTeamB")
|
||||
|
||||
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
|
||||
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
|
||||
|
||||
mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
|
||||
}
|
||||
|
||||
model TeamInvite {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
teamId String
|
||||
type String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation("UserInvitations", fields: [steamId], references: [steamId])
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
title String?
|
||||
message String
|
||||
read Boolean @default(false)
|
||||
persistent Boolean @default(false)
|
||||
actionType String?
|
||||
actionData String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [steamId], references: [steamId])
|
||||
}
|
||||
|
||||
//
|
||||
// ──────────────────────────────────────────────
|
||||
// 🎮 Matches & Spieler
|
||||
// ──────────────────────────────────────────────
|
||||
//
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 🎮 Matches
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
model Match {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
matchType String @default("community")
|
||||
map String?
|
||||
description String?
|
||||
scoreA Int?
|
||||
scoreB Int?
|
||||
|
||||
teamAId String?
|
||||
teamA Team? @relation("MatchTeamA", fields: [teamAId], references: [id])
|
||||
|
||||
teamBId String?
|
||||
teamB Team? @relation("MatchTeamB", fields: [teamBId], references: [id])
|
||||
|
||||
teamAUsers User[] @relation("TeamAPlayers")
|
||||
teamBUsers User[] @relation("TeamBPlayers")
|
||||
|
||||
filePath String?
|
||||
demoFile DemoFile?
|
||||
demoDate DateTime?
|
||||
demoData Json?
|
||||
|
||||
players MatchPlayer[]
|
||||
rankUpdates RankHistory[] @relation("MatchRankHistory")
|
||||
|
||||
roundCount Int?
|
||||
roundHistory Json?
|
||||
winnerTeam String?
|
||||
|
||||
bestOf Int @default(3) // 1 | 3 | 5 – app-seitig validieren
|
||||
matchDate DateTime? // geplante Startzeit (separat von demoDate)
|
||||
mapVote MapVote?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
schedule Schedule?
|
||||
|
||||
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
|
||||
}
|
||||
|
||||
model MatchPlayer {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
matchId String
|
||||
teamId String?
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
user User @relation(fields: [steamId], references: [steamId])
|
||||
|
||||
stats PlayerStats?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([matchId, steamId])
|
||||
}
|
||||
|
||||
model PlayerStats {
|
||||
id String @id @default(uuid())
|
||||
matchId String
|
||||
steamId String
|
||||
|
||||
kills Int
|
||||
assists Int
|
||||
deaths Int
|
||||
headshotPct Float
|
||||
|
||||
totalDamage Float @default(0)
|
||||
utilityDamage Int @default(0)
|
||||
flashAssists Int @default(0)
|
||||
mvps Int @default(0)
|
||||
mvpEliminations Int @default(0)
|
||||
mvpDefuse Int @default(0)
|
||||
mvpPlant Int @default(0)
|
||||
knifeKills Int @default(0)
|
||||
zeusKills Int @default(0)
|
||||
wallbangKills Int @default(0)
|
||||
smokeKills Int @default(0)
|
||||
headshots Int @default(0)
|
||||
noScopes Int @default(0)
|
||||
blindKills Int @default(0)
|
||||
|
||||
aim Int @default(0)
|
||||
|
||||
oneK Int @default(0)
|
||||
twoK Int @default(0)
|
||||
threeK Int @default(0)
|
||||
fourK Int @default(0)
|
||||
fiveK Int @default(0)
|
||||
|
||||
rankOld Int?
|
||||
rankNew Int?
|
||||
rankChange Int?
|
||||
winCount Int?
|
||||
|
||||
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
|
||||
|
||||
@@unique([matchId, steamId])
|
||||
}
|
||||
|
||||
model RankHistory {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
matchId String?
|
||||
|
||||
rankOld Int
|
||||
rankNew Int
|
||||
delta Int
|
||||
winCount Int
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
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
|
||||
description String?
|
||||
map String?
|
||||
date DateTime
|
||||
status ScheduleStatus @default(PENDING)
|
||||
|
||||
teamAId String?
|
||||
teamA Team? @relation("ScheduleTeamA", fields: [teamAId], references: [id])
|
||||
|
||||
teamBId String?
|
||||
teamB Team? @relation("ScheduleTeamB", fields: [teamBId], references: [id])
|
||||
|
||||
createdById String
|
||||
createdBy User @relation("CreatedSchedules", fields: [createdById], references: [steamId])
|
||||
|
||||
confirmedById String?
|
||||
confirmedBy User? @relation("ConfirmedSchedules", fields: [confirmedById], references: [steamId])
|
||||
|
||||
linkedMatchId String? @unique
|
||||
linkedMatch Match? @relation(fields: [linkedMatchId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum ScheduleStatus {
|
||||
PENDING
|
||||
CONFIRMED
|
||||
DECLINED
|
||||
CANCELLED
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
//
|
||||
// ──────────────────────────────────────────────
|
||||
// 📦 Demo-Dateien & CS2 Requests
|
||||
// ──────────────────────────────────────────────
|
||||
//
|
||||
|
||||
model DemoFile {
|
||||
id String @id @default(uuid())
|
||||
matchId String @unique
|
||||
steamId String
|
||||
fileName String @unique
|
||||
filePath String
|
||||
parsed Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
user User @relation(fields: [steamId], references: [steamId])
|
||||
}
|
||||
|
||||
model ServerRequest {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
matchId String
|
||||
reservationId BigInt
|
||||
tvPort BigInt
|
||||
processed Boolean @default(false)
|
||||
failed Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation("MatchRequests", fields: [steamId], references: [steamId])
|
||||
|
||||
@@unique([steamId, matchId])
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 🗺️ Map-Vote
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
enum MapVoteAction {
|
||||
BAN
|
||||
PICK
|
||||
DECIDER
|
||||
}
|
||||
|
||||
model MapVote {
|
||||
id String @id @default(uuid())
|
||||
matchId String @unique
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
|
||||
bestOf Int @default(3)
|
||||
mapPool String[]
|
||||
currentIdx Int @default(0)
|
||||
locked Boolean @default(false)
|
||||
opensAt DateTime?
|
||||
|
||||
leadMinutes Int @default(60)
|
||||
|
||||
adminEditingBy String?
|
||||
adminEditingSince DateTime?
|
||||
|
||||
steps MapVoteStep[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model MapVoteStep {
|
||||
id String @id @default(uuid())
|
||||
voteId String
|
||||
order Int
|
||||
action MapVoteAction
|
||||
|
||||
teamId String?
|
||||
team Team? @relation("VoteStepTeam", fields: [teamId], references: [id])
|
||||
|
||||
map String?
|
||||
chosenAt DateTime?
|
||||
chosenBy String?
|
||||
chooser User? @relation("VoteStepChooser", fields: [chosenBy], references: [steamId])
|
||||
|
||||
vote MapVote @relation(fields: [voteId], references: [id])
|
||||
|
||||
@@unique([voteId, order])
|
||||
@@index([teamId])
|
||||
@@index([chosenBy])
|
||||
}
|
||||
|
||||
model MatchReady {
|
||||
matchId String
|
||||
steamId String
|
||||
acceptedAt DateTime @default(now())
|
||||
|
||||
match Match @relation("MatchReadyMatch", fields: [matchId], references: [id])
|
||||
user User @relation("MatchReadyUser", fields: [steamId], references: [steamId])
|
||||
|
||||
@@id([matchId, steamId])
|
||||
@@index([steamId])
|
||||
}
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
//
|
||||
// ──────────────────────────────────────────────
|
||||
// 🧑 Benutzer, Teams & Verwaltung
|
||||
// ──────────────────────────────────────────────
|
||||
//
|
||||
|
||||
model User {
|
||||
steamId String @id
|
||||
name String?
|
||||
avatar String?
|
||||
location String?
|
||||
isAdmin Boolean @default(false)
|
||||
|
||||
teamId String?
|
||||
team Team? @relation("UserTeam", fields: [teamId], references: [id])
|
||||
ledTeam Team? @relation("TeamLeader")
|
||||
|
||||
matchesAsTeamA Match[] @relation("TeamAPlayers")
|
||||
matchesAsTeamB Match[] @relation("TeamBPlayers")
|
||||
|
||||
premierRank Int?
|
||||
authCode String?
|
||||
lastKnownShareCode String?
|
||||
lastKnownShareCodeDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
invites TeamInvite[] @relation("UserInvitations")
|
||||
notifications Notification[]
|
||||
matchPlayers MatchPlayer[]
|
||||
serverRequests ServerRequest[] @relation("MatchRequests")
|
||||
rankHistory RankHistory[] @relation("UserRankHistory")
|
||||
demoFiles DemoFile[]
|
||||
|
||||
createdSchedules Schedule[] @relation("CreatedSchedules")
|
||||
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
|
||||
|
||||
mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
|
||||
|
||||
status UserStatus @default(offline) // 👈 neu
|
||||
lastActiveAt DateTime? // optional: wann zuletzt aktiv
|
||||
|
||||
readyAcceptances MatchReady[] @relation("MatchReadyUser")
|
||||
|
||||
pterodactylClientApiKey String?
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
online
|
||||
away
|
||||
offline
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
logo String?
|
||||
leaderId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
activePlayers String[]
|
||||
inactivePlayers String[]
|
||||
|
||||
leader User? @relation("TeamLeader", fields: [leaderId], references: [steamId])
|
||||
members User[] @relation("UserTeam")
|
||||
invites TeamInvite[]
|
||||
matchPlayers MatchPlayer[]
|
||||
|
||||
matchesAsTeamA Match[] @relation("MatchTeamA")
|
||||
matchesAsTeamB Match[] @relation("MatchTeamB")
|
||||
|
||||
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
|
||||
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
|
||||
|
||||
mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
|
||||
}
|
||||
|
||||
model TeamInvite {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
teamId String
|
||||
type String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation("UserInvitations", fields: [steamId], references: [steamId])
|
||||
team Team @relation(fields: [teamId], references: [id])
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
title String?
|
||||
message String
|
||||
read Boolean @default(false)
|
||||
persistent Boolean @default(false)
|
||||
actionType String?
|
||||
actionData String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [steamId], references: [steamId])
|
||||
}
|
||||
|
||||
//
|
||||
// ──────────────────────────────────────────────
|
||||
// 🎮 Matches & Spieler
|
||||
// ──────────────────────────────────────────────
|
||||
//
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 🎮 Matches
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
model Match {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
matchType String @default("community")
|
||||
map String?
|
||||
description String?
|
||||
scoreA Int?
|
||||
scoreB Int?
|
||||
|
||||
teamAId String?
|
||||
teamA Team? @relation("MatchTeamA", fields: [teamAId], references: [id])
|
||||
|
||||
teamBId String?
|
||||
teamB Team? @relation("MatchTeamB", fields: [teamBId], references: [id])
|
||||
|
||||
teamAUsers User[] @relation("TeamAPlayers")
|
||||
teamBUsers User[] @relation("TeamBPlayers")
|
||||
|
||||
filePath String?
|
||||
demoFile DemoFile?
|
||||
demoDate DateTime?
|
||||
demoData Json?
|
||||
|
||||
players MatchPlayer[]
|
||||
rankUpdates RankHistory[] @relation("MatchRankHistory")
|
||||
|
||||
roundCount Int?
|
||||
roundHistory Json?
|
||||
winnerTeam String?
|
||||
|
||||
bestOf Int @default(3) // 1 | 3 | 5 – app-seitig validieren
|
||||
matchDate DateTime? // geplante Startzeit (separat von demoDate)
|
||||
mapVote MapVote?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
schedule Schedule?
|
||||
|
||||
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
|
||||
}
|
||||
|
||||
model MatchPlayer {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
matchId String
|
||||
teamId String?
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
user User @relation(fields: [steamId], references: [steamId])
|
||||
|
||||
stats PlayerStats?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([matchId, steamId])
|
||||
}
|
||||
|
||||
model PlayerStats {
|
||||
id String @id @default(uuid())
|
||||
matchId String
|
||||
steamId String
|
||||
|
||||
kills Int
|
||||
assists Int
|
||||
deaths Int
|
||||
headshotPct Float
|
||||
|
||||
totalDamage Float @default(0)
|
||||
utilityDamage Int @default(0)
|
||||
flashAssists Int @default(0)
|
||||
mvps Int @default(0)
|
||||
mvpEliminations Int @default(0)
|
||||
mvpDefuse Int @default(0)
|
||||
mvpPlant Int @default(0)
|
||||
knifeKills Int @default(0)
|
||||
zeusKills Int @default(0)
|
||||
wallbangKills Int @default(0)
|
||||
smokeKills Int @default(0)
|
||||
headshots Int @default(0)
|
||||
noScopes Int @default(0)
|
||||
blindKills Int @default(0)
|
||||
|
||||
aim Int @default(0)
|
||||
|
||||
oneK Int @default(0)
|
||||
twoK Int @default(0)
|
||||
threeK Int @default(0)
|
||||
fourK Int @default(0)
|
||||
fiveK Int @default(0)
|
||||
|
||||
rankOld Int?
|
||||
rankNew Int?
|
||||
rankChange Int?
|
||||
winCount Int?
|
||||
|
||||
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
|
||||
|
||||
@@unique([matchId, steamId])
|
||||
}
|
||||
|
||||
model RankHistory {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
matchId String?
|
||||
|
||||
rankOld Int
|
||||
rankNew Int
|
||||
delta Int
|
||||
winCount Int
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
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
|
||||
description String?
|
||||
map String?
|
||||
date DateTime
|
||||
status ScheduleStatus @default(PENDING)
|
||||
|
||||
teamAId String?
|
||||
teamA Team? @relation("ScheduleTeamA", fields: [teamAId], references: [id])
|
||||
|
||||
teamBId String?
|
||||
teamB Team? @relation("ScheduleTeamB", fields: [teamBId], references: [id])
|
||||
|
||||
createdById String
|
||||
createdBy User @relation("CreatedSchedules", fields: [createdById], references: [steamId])
|
||||
|
||||
confirmedById String?
|
||||
confirmedBy User? @relation("ConfirmedSchedules", fields: [confirmedById], references: [steamId])
|
||||
|
||||
linkedMatchId String? @unique
|
||||
linkedMatch Match? @relation(fields: [linkedMatchId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum ScheduleStatus {
|
||||
PENDING
|
||||
CONFIRMED
|
||||
DECLINED
|
||||
CANCELLED
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
//
|
||||
// ──────────────────────────────────────────────
|
||||
// 📦 Demo-Dateien & CS2 Requests
|
||||
// ──────────────────────────────────────────────
|
||||
//
|
||||
|
||||
model DemoFile {
|
||||
id String @id @default(uuid())
|
||||
matchId String @unique
|
||||
steamId String
|
||||
fileName String @unique
|
||||
filePath String
|
||||
parsed Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
user User @relation(fields: [steamId], references: [steamId])
|
||||
}
|
||||
|
||||
model ServerRequest {
|
||||
id String @id @default(uuid())
|
||||
steamId String
|
||||
matchId String
|
||||
reservationId BigInt
|
||||
tvPort BigInt
|
||||
processed Boolean @default(false)
|
||||
failed Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation("MatchRequests", fields: [steamId], references: [steamId])
|
||||
|
||||
@@unique([steamId, matchId])
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 🗺️ Map-Vote
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
enum MapVoteAction {
|
||||
BAN
|
||||
PICK
|
||||
DECIDER
|
||||
}
|
||||
|
||||
model MapVote {
|
||||
id String @id @default(uuid())
|
||||
matchId String @unique
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
|
||||
bestOf Int @default(3)
|
||||
mapPool String[]
|
||||
currentIdx Int @default(0)
|
||||
locked Boolean @default(false)
|
||||
opensAt DateTime?
|
||||
|
||||
leadMinutes Int @default(60)
|
||||
|
||||
adminEditingBy String?
|
||||
adminEditingSince DateTime?
|
||||
|
||||
steps MapVoteStep[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model MapVoteStep {
|
||||
id String @id @default(uuid())
|
||||
voteId String
|
||||
order Int
|
||||
action MapVoteAction
|
||||
|
||||
teamId String?
|
||||
team Team? @relation("VoteStepTeam", fields: [teamId], references: [id])
|
||||
|
||||
map String?
|
||||
chosenAt DateTime?
|
||||
chosenBy String?
|
||||
chooser User? @relation("VoteStepChooser", fields: [chosenBy], references: [steamId])
|
||||
|
||||
vote MapVote @relation(fields: [voteId], references: [id])
|
||||
|
||||
@@unique([voteId, order])
|
||||
@@index([teamId])
|
||||
@@index([chosenBy])
|
||||
}
|
||||
|
||||
model MatchReady {
|
||||
matchId String
|
||||
steamId String
|
||||
acceptedAt DateTime @default(now())
|
||||
|
||||
match Match @relation("MatchReadyMatch", fields: [matchId], references: [id])
|
||||
user User @relation("MatchReadyUser", fields: [steamId], references: [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="Privacy" href="/admin/privacy" />
|
||||
<Tab name="Teams" href="/admin/teams" />
|
||||
<Tab name="Serververwaltung" href="/admin/server" />
|
||||
</Tabs>
|
||||
<div className="mt-6">
|
||||
{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));
|
||||
|
||||
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
|
||||
async function setAdminEdit(voteId: string, by: string | null) {
|
||||
return prisma.mapVote.update({
|
||||
@ -316,6 +391,8 @@ function toDeMapName(key: string) {
|
||||
if (key.startsWith('de_')) return key
|
||||
return `de_${key}`
|
||||
}
|
||||
|
||||
// ⬇️ buildMatchJson anpassen: matchid statt "" → zufälliger Integer
|
||||
function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
|
||||
const bestOf = match.bestOf ?? state.bestOf ?? 3
|
||||
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
|
||||
@ -333,8 +410,11 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
|
||||
const team1Players = playersMapFromList(match.teamA?.players)
|
||||
const team2Players = playersMapFromList(match.teamB?.players)
|
||||
|
||||
// 👇 hier neu: zufällige Integer-ID
|
||||
const rndId = makeRandomMatchId()
|
||||
|
||||
return {
|
||||
matchid: "",
|
||||
matchid: rndId, // vorher: ""
|
||||
team1: { name: team1Name, players: team1Players },
|
||||
team2: { name: team2Name, players: team2Players },
|
||||
num_maps: bestOf,
|
||||
@ -352,7 +432,7 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
|
||||
|
||||
async function exportMatchToSftpDirect(match: any, vote: any) {
|
||||
try {
|
||||
const SFTPClient = (await import('ssh2-sftp-client')).default // dyn. import
|
||||
const SFTPClient = (await import('ssh2-sftp-client')).default
|
||||
const mLike: MatchLike = {
|
||||
id: match.id,
|
||||
bestOf: match.bestOf ?? vote.bestOf ?? 3,
|
||||
@ -367,15 +447,13 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
|
||||
locked: vote.locked,
|
||||
}
|
||||
|
||||
if (!sLike.locked) return // nur exportieren, wenn locked
|
||||
|
||||
if (!sLike.locked) return
|
||||
const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3
|
||||
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
|
||||
if (chosen.length < bestOf) return // vollständig sicherstellen
|
||||
if (chosen.length < bestOf) return
|
||||
|
||||
const json = buildMatchJson(mLike, sLike)
|
||||
const jsonStr = JSON.stringify(json, null, 2)
|
||||
|
||||
const filename = `${match.id}.json`
|
||||
|
||||
const url = process.env.PTERO_SERVER_SFTP_URL || ''
|
||||
@ -405,11 +483,17 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
|
||||
await sftp.end()
|
||||
|
||||
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) {
|
||||
console.error('[mapvote] Export fehlgeschlagen:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---------- kleine Helfer für match-ready Payload ---------- */
|
||||
|
||||
function deriveChosenSteps(vote: any) {
|
||||
|
||||
@ -6,12 +6,27 @@ import { useSession } from 'next-auth/react'
|
||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||
import MatchReadyOverlay from './MatchReadyOverlay'
|
||||
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore'
|
||||
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore' // ⬅️ neu
|
||||
|
||||
/**
|
||||
* Erwartet SSE-Events:
|
||||
* - 'match-ready' { matchId, firstMap:{label,bg}, participants:string[] }
|
||||
* - 'map-vote-updated' { matchId, locked, bestOf, steps, mapVisuals, teams:{teamA,teamB} }
|
||||
*/
|
||||
// ---- kleiner In-Memory Cache für connectHref pro matchId
|
||||
const CONNECT_CACHE = new Map<string | undefined, string>()
|
||||
|
||||
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() {
|
||||
const router = useRouter()
|
||||
@ -21,7 +36,9 @@ export default function ReadyOverlayHost() {
|
||||
|
||||
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) =>
|
||||
typeof window !== 'undefined' &&
|
||||
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) {
|
||||
const matchId: string | undefined = payload?.matchId
|
||||
if (!matchId) return null
|
||||
@ -65,49 +82,71 @@ export default function ReadyOverlayHost() {
|
||||
return { matchId, firstMap: { key, label, bg }, participants }
|
||||
}
|
||||
|
||||
// --- SSE: 'match-ready' / 'map-vote-updated' öffnen das Overlay ---
|
||||
// Events: 'match-ready' & 'map-vote-updated'
|
||||
useEffect(() => {
|
||||
if (!lastEvent || !mySteamId) return
|
||||
|
||||
if (lastEvent.type === 'match-ready') {
|
||||
const m = lastEvent.payload?.matchId
|
||||
const participants: string[] = lastEvent.payload?.participants ?? []
|
||||
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
|
||||
(async () => {
|
||||
const m: string | undefined = lastEvent.payload?.matchId
|
||||
const participants: string[] = lastEvent.payload?.participants ?? []
|
||||
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
|
||||
|
||||
const label = lastEvent.payload?.firstMap?.label ?? '?'
|
||||
const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
|
||||
// ⬇️ Roster persistent speichern (für Reconnect-Banner)
|
||||
setRoster(participants)
|
||||
|
||||
showWithDelay(
|
||||
{
|
||||
matchId: m,
|
||||
mapLabel: label,
|
||||
mapBg: bg,
|
||||
nextHref: `/match-details/${m}/radar`,
|
||||
},
|
||||
3000
|
||||
)
|
||||
const label = lastEvent.payload?.firstMap?.label ?? '?'
|
||||
const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
|
||||
|
||||
const connectHref =
|
||||
(await getConnectHref(m)) ||
|
||||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
|
||||
null
|
||||
|
||||
showWithDelay(
|
||||
{
|
||||
matchId: m,
|
||||
mapLabel: label,
|
||||
mapBg: bg,
|
||||
nextHref: `/match-details/${m}/radar`,
|
||||
connectHref: connectHref ?? undefined,
|
||||
},
|
||||
3000
|
||||
)
|
||||
})()
|
||||
return
|
||||
}
|
||||
|
||||
if (lastEvent.type === 'map-vote-updated') {
|
||||
const summary = deriveReadySummary(lastEvent.payload)
|
||||
if (!summary) return
|
||||
const { matchId: m, firstMap, participants } = summary
|
||||
if (!participants.includes(mySteamId) || isAccepted(m)) return
|
||||
(async () => {
|
||||
const summary = deriveReadySummary(lastEvent.payload)
|
||||
if (!summary) return
|
||||
const { matchId: m, firstMap, participants } = summary
|
||||
if (!participants.includes(mySteamId) || isAccepted(m)) return
|
||||
|
||||
showWithDelay(
|
||||
{
|
||||
matchId: m,
|
||||
mapLabel: firstMap.label,
|
||||
mapBg: firstMap.bg,
|
||||
nextHref: `/match-details/${m}/radar`,
|
||||
},
|
||||
3000
|
||||
)
|
||||
// ⬇️ Roster persistent speichern
|
||||
setRoster(participants)
|
||||
|
||||
const connectHref =
|
||||
(await getConnectHref(m)) ||
|
||||
process.env.NEXT_PUBLIC_CONNECT_HREF ||
|
||||
null
|
||||
|
||||
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(() => {
|
||||
if (!lastEvent) return
|
||||
if (lastEvent.type === 'map-vote-reset') {
|
||||
@ -115,27 +154,23 @@ export default function ReadyOverlayHost() {
|
||||
if (m && typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(`match:${m}:readyAccepted`)
|
||||
}
|
||||
clearRoster() // ⬅️ neu
|
||||
if (open) hide()
|
||||
}
|
||||
}, [lastEvent, open, hide])
|
||||
}, [lastEvent, open, hide, clearRoster])
|
||||
|
||||
if (!open || !data) return null
|
||||
|
||||
return (
|
||||
<MatchReadyOverlay
|
||||
open={open}
|
||||
matchId={data.matchId} // ✅ neu: fürs Ready-Polling im Overlay
|
||||
matchId={data.matchId}
|
||||
mapLabel={data.mapLabel}
|
||||
mapBg={data.mapBg}
|
||||
onAccept={async () => {
|
||||
// Backend optional informieren (Overlay bleibt offen!)
|
||||
// await fetch(`/api/matches/${data.matchId}/ready-accept`, { method: 'POST' }).catch(() => {})
|
||||
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'
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { usePresenceStore } from '@/app/lib/usePresenceStore'
|
||||
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'
|
||||
|
||||
@ -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 setJoin = usePresenceStore(s => s.setJoin)
|
||||
const setLeave = usePresenceStore(s => s.setLeave)
|
||||
|
||||
const setMapKey = useTelemetryStore(s => s.setMapKey)
|
||||
|
||||
// interne Refs für saubere Handler
|
||||
const setMapKey = useTelemetryStore(s => s.setMapKey)
|
||||
const phase = useTelemetryStore(s => s.phase)
|
||||
const setPhase = useTelemetryStore(s => s.setPhase)
|
||||
|
||||
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 retryRef = useRef<number | 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(() => {
|
||||
aliveRef.current = true
|
||||
|
||||
@ -65,20 +96,38 @@ export default function TelemetrySocket() {
|
||||
if (!msg) return
|
||||
|
||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||
// kompletter Presence-Snapshot
|
||||
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) {
|
||||
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') {
|
||||
const sid = msg.steamId ?? msg.steam_id ?? msg.id
|
||||
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') {
|
||||
const key = msg.name.toLowerCase()
|
||||
if (process.env.NODE_ENV!=='production') console.debug('[TelemetrySocket] map:', 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)
|
||||
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 { MatchStats } from '@/app/types/match'
|
||||
import Card from '../../../Card'
|
||||
import UserClips from '../../../UserClips'
|
||||
// import UserClips from '../../../UserClips'
|
||||
|
||||
type MatchStatsProps = {
|
||||
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
|
||||
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
type ReadyOverlayData = {
|
||||
export type ReadyOverlayData = {
|
||||
matchId: string
|
||||
mapLabel: string
|
||||
mapBg: string
|
||||
nextHref?: string // Zielroute nach "ACCEPT"
|
||||
nextHref?: string
|
||||
connectHref?: string // ⬅️ neu
|
||||
}
|
||||
|
||||
type State = {
|
||||
type ReadyOverlayState = {
|
||||
open: boolean
|
||||
data: ReadyOverlayData | null
|
||||
showAt?: number | null
|
||||
showWithDelay: (data: ReadyOverlayData, delayMs: number) => void
|
||||
show: (data: ReadyOverlayData) => void
|
||||
showWithDelay: (data: ReadyOverlayData, delayMs?: number) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
export const useReadyOverlayStore = create<State>((set) => ({
|
||||
export const useReadyOverlayStore = create<ReadyOverlayState>((set) => ({
|
||||
open: false,
|
||||
data: null,
|
||||
showAt: null,
|
||||
showWithDelay: (data, delayMs) => {
|
||||
const showAt = Date.now() + Math.max(0, delayMs)
|
||||
set({ data, showAt })
|
||||
const step = () => {
|
||||
const t = Date.now()
|
||||
if (t >= showAt) {
|
||||
set({ open: true, showAt: null })
|
||||
} else {
|
||||
requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(step)
|
||||
show: (data) => set({ open: true, data }),
|
||||
showWithDelay: (data, delayMs = 0) => {
|
||||
if (delayMs <= 0) return set({ open: true, data })
|
||||
setTimeout(() => set({ open: true, data }), delayMs)
|
||||
},
|
||||
hide: () => set({ open: false, data: null, showAt: null }),
|
||||
hide: () => set({ open: false, data: null }),
|
||||
}))
|
||||
|
||||
@ -1,13 +1,26 @@
|
||||
// /src/app/lib/useTelemetryStore.ts
|
||||
'use client'
|
||||
// /app/lib/useTelemetryStore.ts
|
||||
import { create } from 'zustand'
|
||||
|
||||
type State = {
|
||||
type TelemetryState = {
|
||||
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,
|
||||
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 StatusDot from '../components/StatusDot'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import StaticEffects from '../components/radar/StaticEffects'
|
||||
|
||||
|
||||
/* ───────── UI config ───────── */
|
||||
const UI = {
|
||||
@ -776,7 +778,9 @@ export default function LiveRadar() {
|
||||
}
|
||||
} else {
|
||||
// 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)
|
||||
@ -941,12 +945,13 @@ export default function LiveRadar() {
|
||||
...prev,
|
||||
...it,
|
||||
spawnedAt: prev?.spawnedAt ?? it.spawnedAt ?? now,
|
||||
headingRad: (it.headingRad ?? prev?.headingRad ?? null),
|
||||
_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)
|
||||
@ -956,11 +961,14 @@ export default function LiveRadar() {
|
||||
if (nade.phase === 'effect' || nade.phase === 'exploded') {
|
||||
const left = (typeof nade.lifeLeftMs === 'number')
|
||||
? nade.lifeLeftMs
|
||||
: (typeof nade.expiresAt === 'number' ? (nade.expiresAt - Date.now()) : null);
|
||||
if (left != null && left <= 0) {
|
||||
: (typeof nade.expiresAt === 'number' ? (nade.expiresAt - now) : null);
|
||||
|
||||
// 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);
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nade.phase === 'projectile') {
|
||||
@ -1433,34 +1441,6 @@ export default function LiveRadar() {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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.map(tr => {
|
||||
const pts = tr.pts.map(p => {
|
||||
@ -1481,190 +1461,65 @@ export default function LiveRadar() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Grenades: Projectiles + Effekte */}
|
||||
{grenades
|
||||
//.filter(shouldShowGrenade)
|
||||
.map((g) => {
|
||||
const P = worldToPx(g.x, g.y)
|
||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
||||
{/* 🔽 NEU: statische Effekte (Smoke/Molotov/Incendiary/Decoy/Flash) + Bombe */}
|
||||
<StaticEffects
|
||||
grenades={grenades}
|
||||
bomb={bomb}
|
||||
worldToPx={worldToPx}
|
||||
unitsToPx={unitsToPx}
|
||||
ui={{ nade: UI.nade, player: { bombStroke: UI.player.bombStroke } }}
|
||||
beepState={beepState}
|
||||
/>
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
{/* Grenades: nur Projectiles + HE-Explosionen (statische Effekte macht <StaticEffects />) */}
|
||||
{grenades.map((g) => {
|
||||
const P = worldToPx(g.x, g.y)
|
||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
||||
|
||||
const rBase = Math.max(10, unitsToPx(28))
|
||||
const iconSize = Math.max(24, rBase * 1.8)
|
||||
const isActive = bomb.status === 'planted' || bomb.status === 'defusing'
|
||||
const isDefused = bomb.status === 'defused'
|
||||
const iconColor = bomb.status === 'planted' ? '#ef4444' : (isDefused ? '#10b981' : '#e5e7eb')
|
||||
const maskId = `bomb-mask-${bomb.changedAt}`
|
||||
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={`bomb-${bomb.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>
|
||||
)}
|
||||
// 1) Projektil-Icon
|
||||
if (g.phase === 'projectile') {
|
||||
const size = Math.max(18, 22)
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
<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>
|
||||
<mask id={maskId}>
|
||||
<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>
|
||||
)
|
||||
})()}
|
||||
// statische Effekte (smoke/molotov/incendiary/decoy/flash) werden NICHT mehr hier gezeichnet
|
||||
return null
|
||||
})}
|
||||
|
||||
{/* Spieler */}
|
||||
{players
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!!
|
||||
/* 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 = {}
|
||||
|
||||
/**
|
||||
* Prisma Client JS version: 6.15.0
|
||||
* Query Engine version: 85179d7826409ee107a6ba334b5e305ae3fba9fb
|
||||
* Prisma Client JS version: 6.16.1
|
||||
* Query Engine version: 1c57fdcd7e44b29b9313256c76699e91c3ac3c43
|
||||
*/
|
||||
Prisma.prismaVersion = {
|
||||
client: "6.15.0",
|
||||
engine: "85179d7826409ee107a6ba334b5e305ae3fba9fb"
|
||||
client: "6.16.1",
|
||||
engine: "1c57fdcd7e44b29b9313256c76699e91c3ac3c43"
|
||||
}
|
||||
|
||||
Prisma.PrismaClientKnownRequestError = () => {
|
||||
@ -133,7 +133,8 @@ exports.Prisma.UserScalarFieldEnum = {
|
||||
lastKnownShareCodeDate: 'lastKnownShareCodeDate',
|
||||
createdAt: 'createdAt',
|
||||
status: 'status',
|
||||
lastActiveAt: 'lastActiveAt'
|
||||
lastActiveAt: 'lastActiveAt',
|
||||
pterodactylClientApiKey: 'pterodactylClientApiKey'
|
||||
};
|
||||
|
||||
exports.Prisma.TeamScalarFieldEnum = {
|
||||
@ -310,6 +311,16 @@ exports.Prisma.MatchReadyScalarFieldEnum = {
|
||||
acceptedAt: 'acceptedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.ServerConfigScalarFieldEnum = {
|
||||
id: 'id',
|
||||
serverIp: 'serverIp',
|
||||
serverPassword: 'serverPassword',
|
||||
pterodactylServerId: 'pterodactylServerId',
|
||||
pterodactylServerApiKey: 'pterodactylServerApiKey',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@ -369,7 +380,8 @@ exports.Prisma.ModelName = {
|
||||
ServerRequest: 'ServerRequest',
|
||||
MapVote: 'MapVote',
|
||||
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",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
"browser": "default.js",
|
||||
"exports": {
|
||||
"./client": {
|
||||
"require": {
|
||||
@ -125,6 +125,12 @@
|
||||
"import": "./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": {
|
||||
"require": "./generator-build/index.js",
|
||||
"import": "./generator-build/index.js",
|
||||
@ -145,6 +151,33 @@
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
||||
"version": "6.15.0",
|
||||
"sideEffects": false
|
||||
"version": "6.16.1",
|
||||
"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
|
||||
|
||||
readyAcceptances MatchReady[] @relation("MatchReadyUser")
|
||||
|
||||
pterodactylClientApiKey String?
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
@ -370,3 +372,17 @@ model MatchReady {
|
||||
@@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
|
||||
}
|
||||
|
||||
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