This commit is contained in:
Linrador 2025-09-17 14:07:46 +02:00
parent e693af798b
commit 237be94ebe
42 changed files with 3408 additions and 1153 deletions

2
.env
View File

@ -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
View File

@ -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"

View File

@ -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",

View File

@ -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
}

View File

@ -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}

View 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>
)
}

View 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' } })
}

View 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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 {
// 910-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) {

View File

@ -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}
/>
)
}

View File

@ -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>
)}
</>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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[] }

View 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>
</>
)
}

View 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}`
}

View 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)
}
}
}
)
)

View File

@ -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 }),
}))

View File

@ -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() }),
}))

View File

@ -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

View File

@ -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

View File

@ -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'
};
/**

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -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"
}
}
}

File diff suppressed because one or more lines are too long

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

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

View File

@ -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
}

View 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')

View File

@ -0,0 +1,4 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!!
/* eslint-disable */
export default import('./query_engine_bg.wasm')

View File

@ -1 +1 @@
export * from "./index"
export * from "./default"

File diff suppressed because one or more lines are too long