updated profile
12
.env
@ -34,14 +34,4 @@ NEXT_PUBLIC_CS2_GAME_WS_SCHEME=wss
|
||||
|
||||
NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000"
|
||||
|
||||
FACEIT_CLIENT_ID=a0bf42fd-73e8-401c-84d7-5a3a88ff28f6
|
||||
FACEIT_CLIENT_SECRET=kx6uMZsBcXSm75Y7Sfj2nKtbtZOcxGVwsynvxBJ1
|
||||
FACEIT_REDIRECT_URI=https://ironieopen.de/api/faceit/callback
|
||||
|
||||
# aus den Docs übernehmen:
|
||||
FACEIT_AUTH_URL=...authorize endpoint...
|
||||
FACEIT_TOKEN_URL=...token endpoint...
|
||||
FACEIT_API_BASE=https://open.faceit.com/data/v4
|
||||
FACEIT_SCOPES=openid profile email # je nach Bedarf erweitern
|
||||
FACEIT_COOKIE_NAME=faceit_pkce # optional
|
||||
FACEIT_COOKIE_SECRET=super-long-random-secret
|
||||
FACEIT_API_KEY=28ff4916-65da-4415-ba67-3d6d6b5dc850
|
||||
|
||||
@ -1,422 +1,445 @@
|
||||
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?
|
||||
|
||||
timeZone String? // IANA-TZ, z.B. "Europe/Berlin"
|
||||
|
||||
// ✅ Datenschutz: darf eingeladen werden?
|
||||
canBeInvited Boolean @default(true)
|
||||
|
||||
|
||||
// ⬇️ Dauerhafter Ban-Status (zuletzt bekannter Stand)
|
||||
vacBanned Boolean? @default(false)
|
||||
numberOfVACBans Int? @default(0)
|
||||
numberOfGameBans Int? @default(0)
|
||||
daysSinceLastBan Int? @default(0)
|
||||
communityBanned Boolean? @default(false)
|
||||
economyBan String?
|
||||
lastBanCheck DateTime?
|
||||
|
||||
// FaceIt Account
|
||||
faceitId String? @unique
|
||||
faceitNickname String?
|
||||
faceitAvatar String?
|
||||
faceitLinkedAt DateTime?
|
||||
|
||||
// Falls du Tokens speichern willst (siehe Security-Hinweise unten)
|
||||
faceitAccessToken String?
|
||||
faceitRefreshToken String?
|
||||
faceitTokenExpiresAt DateTime?
|
||||
|
||||
@@index([vacBanned])
|
||||
@@index([numberOfVACBans])
|
||||
@@index([numberOfGameBans])
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
online
|
||||
away
|
||||
offline
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
logo String?
|
||||
logoUpdatedAt DateTime? @default(now())
|
||||
|
||||
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?
|
||||
|
||||
matchDate DateTime? // geplante Startzeit (separat von demoDate)
|
||||
mapVote MapVote?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
schedule Schedule?
|
||||
|
||||
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
|
||||
|
||||
cs2MatchId BigInt? @unique // <— wichtig (Postgres lässt mehrere NULLs zu)
|
||||
exportedAt DateTime? // wann die JSON exportiert wurde
|
||||
}
|
||||
|
||||
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], onDelete: Cascade)
|
||||
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], onDelete: Cascade)
|
||||
}
|
||||
|
||||
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], onDelete: Cascade)
|
||||
|
||||
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], onDelete: Cascade)
|
||||
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], onDelete: Cascade)
|
||||
|
||||
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], onDelete: Cascade)
|
||||
|
||||
@@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], onDelete: Cascade)
|
||||
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
|
||||
}
|
||||
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?
|
||||
|
||||
timeZone String? // IANA-TZ, z.B. "Europe/Berlin"
|
||||
|
||||
// ✅ Datenschutz: darf eingeladen werden?
|
||||
canBeInvited Boolean @default(true)
|
||||
|
||||
// ⬇️ Dauerhafter Ban-Status (zuletzt bekannter Stand)
|
||||
vacBanned Boolean? @default(false)
|
||||
numberOfVACBans Int? @default(0)
|
||||
numberOfGameBans Int? @default(0)
|
||||
daysSinceLastBan Int? @default(0)
|
||||
communityBanned Boolean? @default(false)
|
||||
economyBan String?
|
||||
lastBanCheck DateTime?
|
||||
|
||||
// FaceIt Account
|
||||
faceitId String? @unique
|
||||
faceitNickname String?
|
||||
faceitAvatar String?
|
||||
faceitCountry String?
|
||||
faceitUrl String?
|
||||
faceitVerified Boolean? @default(false)
|
||||
faceitActivatedAt DateTime?
|
||||
|
||||
faceitSteamId64 String?
|
||||
faceitGames FaceitGameStat[]
|
||||
|
||||
@@index([vacBanned])
|
||||
@@index([numberOfVACBans])
|
||||
@@index([numberOfGameBans])
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
online
|
||||
away
|
||||
offline
|
||||
}
|
||||
|
||||
enum FaceitGameId {
|
||||
csgo
|
||||
cs2
|
||||
}
|
||||
|
||||
model FaceitGameStat {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [userSteamId], references: [steamId], onDelete: Cascade)
|
||||
userSteamId String
|
||||
game FaceitGameId
|
||||
region String? // "EU"
|
||||
gamePlayerId String? // "76561198000414190"
|
||||
gamePlayerName String? // "Army"
|
||||
skillLevel Int? // 4
|
||||
elo Int? // 1045
|
||||
skillLabel String?
|
||||
gameProfileId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userSteamId, game])
|
||||
@@index([game, elo])
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
logo String?
|
||||
logoUpdatedAt DateTime? @default(now())
|
||||
|
||||
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?
|
||||
|
||||
matchDate DateTime? // geplante Startzeit (separat von demoDate)
|
||||
mapVote MapVote?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
schedule Schedule?
|
||||
|
||||
readyAcceptances MatchReady[] @relation("MatchReadyMatch")
|
||||
|
||||
cs2MatchId BigInt? @unique // <— wichtig (Postgres lässt mehrere NULLs zu)
|
||||
exportedAt DateTime? // wann die JSON exportiert wurde
|
||||
}
|
||||
|
||||
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], onDelete: Cascade)
|
||||
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], onDelete: Cascade)
|
||||
}
|
||||
|
||||
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], onDelete: Cascade)
|
||||
|
||||
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], onDelete: Cascade)
|
||||
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], onDelete: Cascade)
|
||||
|
||||
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], onDelete: Cascade)
|
||||
|
||||
@@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], onDelete: Cascade)
|
||||
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
|
||||
}
|
||||
|
||||
BIN
public/assets/img/icons/faceit/1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/img/icons/faceit/10.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/img/icons/faceit/2.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/img/icons/faceit/3.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/img/icons/faceit/4.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/img/icons/faceit/5.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/assets/img/icons/faceit/6.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/img/icons/faceit/7.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/img/icons/faceit/8.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/img/icons/faceit/9.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
72
public/assets/img/logos/faceit.svg
Normal file
@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 113.58667 89.746666"
|
||||
height="89.746666"
|
||||
width="113.58667"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
version="1.1"><metadata
|
||||
id="metadata8"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs6"><clipPath
|
||||
id="clipPath20"
|
||||
clipPathUnits="userSpaceOnUse"><path
|
||||
id="path18"
|
||||
d="M 0,0 H 852 V 673 H 0 Z" /></clipPath><clipPath
|
||||
id="clipPath26"
|
||||
clipPathUnits="userSpaceOnUse"><path
|
||||
id="path24"
|
||||
d="m 851.887,666.508 c 0,6.769 -7.481,8.91 -11.047,3.562 C 737.148,510.789 678.707,419.922 625.969,337.254 c -195.985,0 -478.203,0 -618.24244,0 -7.839841,0 -10.6914,-9.981 -3.92187,-12.473 C 260.367,227.855 630.961,82.1172 837.992,0.515625 843.336,-1.625 851.887,3.36328 851.887,6.92969 V 666.508 Z" /></clipPath><linearGradient
|
||||
id="linearGradient46"
|
||||
spreadMethod="pad"
|
||||
gradientTransform="matrix(0,-673.08,673.08,0,425.944,673.08)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
y2="0"
|
||||
x2="1"
|
||||
y1="0"
|
||||
x1="0"><stop
|
||||
id="stop28"
|
||||
offset="0"
|
||||
style="stop-opacity:1;stop-color:#ff5f00" /><stop
|
||||
id="stop30"
|
||||
offset="0.2652"
|
||||
style="stop-opacity:1;stop-color:#ff6506" /><stop
|
||||
id="stop32"
|
||||
offset="0.4804"
|
||||
style="stop-opacity:1;stop-color:#ff6e0f" /><stop
|
||||
id="stop34"
|
||||
offset="0.4816"
|
||||
style="stop-opacity:1;stop-color:#fe690c" /><stop
|
||||
id="stop36"
|
||||
offset="0.4862"
|
||||
style="stop-opacity:1;stop-color:#fd5e05" /><stop
|
||||
id="stop38"
|
||||
offset="0.4927"
|
||||
style="stop-opacity:1;stop-color:#fc5701" /><stop
|
||||
id="stop40"
|
||||
offset="0.5108"
|
||||
style="stop-opacity:1;stop-color:#fc5500" /><stop
|
||||
id="stop42"
|
||||
offset="0.775"
|
||||
style="stop-opacity:1;stop-color:#fe6300" /><stop
|
||||
id="stop44"
|
||||
offset="1"
|
||||
style="stop-opacity:1;stop-color:#ff6900" /></linearGradient></defs><g
|
||||
transform="matrix(1.3333333,0,0,-1.3333333,0,89.746667)"
|
||||
id="g10"><g
|
||||
transform="scale(0.1)"
|
||||
id="g12"><g
|
||||
id="g14"><g
|
||||
clip-path="url(#clipPath20)"
|
||||
id="g16"><g
|
||||
clip-path="url(#clipPath26)"
|
||||
id="g22"><path
|
||||
id="path48"
|
||||
style="fill:url(#linearGradient46);fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
d="m 836,0 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 h -3 v 1 h -3 v 1 h -2 v 1 H 8 v 1 H 5 v 1 H 3 v 1 H 1 v 2 H 0 v 7 h 1 v 1 h 1 v 1 h 1 v 1 h 2 v 1 h 621 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 2 h 1 v 1 h 1 v 1 h 8 v -1 h 1 v -2 h 1 V 4 h -1 V 3 h -1 V 2 h -2 V 1 h -2 V 0" /></g></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
52
src/app/[locale]/components/FaceitElo.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
const iconPx: Record<Size, number> = { sm: 16, md: 18, lg: 22 }
|
||||
const textCls: Record<Size, string> = { sm: 'text-xs', md: 'text-sm', lg: 'text-base' }
|
||||
|
||||
export default function FaceitElo({
|
||||
elo,
|
||||
size = 'md',
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
elo?: number | null
|
||||
size?: Size
|
||||
className?: string
|
||||
title?: string
|
||||
}) {
|
||||
const formatted = typeof elo === 'number' ? elo.toLocaleString('de-DE') : '–'
|
||||
const w = iconPx[size]
|
||||
const h = Math.round(w / 2) // viewBox 24x12 -> Höhe = 1/2 Breite
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 text-neutral-300',
|
||||
textCls[size],
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
title={title ?? 'Faceit Elo'}
|
||||
aria-label={title ?? 'Faceit Elo'}
|
||||
>
|
||||
{/* Original Faceit Elo Icon */}
|
||||
<svg
|
||||
viewBox="0 0 24 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={w}
|
||||
height={h}
|
||||
className="shrink-0"
|
||||
aria-hidden
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M12 3c0 .463-.105.902-.292 1.293l1.998 2A2.97 2.97 0 0115 6a2.99 2.99 0 011.454.375l1.921-1.921a3 3 0 111.5 1.328l-2.093 2.093a3 3 0 11-5.49-.168l-1.999-2a2.992 2.992 0 01-2.418.074L5.782 7.876a3 3 0 11-1.328-1.5l1.921-1.921A3 3 0 1112 3z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="tabular-nums">{formatted}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
62
src/app/[locale]/components/FaceitLevelBadge.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
import Image from 'next/image'
|
||||
import React from 'react'
|
||||
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
const sizePx: Record<Size, number> = { sm: 18, md: 22, lg: 28 }
|
||||
|
||||
/** Elo → Faceit Level (1..10) nach deinen Ranges */
|
||||
function levelFromElo(elo?: number | null): number | null {
|
||||
if (elo == null || Number.isNaN(elo)) return null
|
||||
if (elo >= 2001) return 10 // 2001+
|
||||
if (elo >= 1751) return 9 // 1751–2000
|
||||
if (elo >= 1531) return 8 // 1531–1750
|
||||
if (elo >= 1351) return 7 // 1351–1530
|
||||
if (elo >= 1201) return 6 // 1201–1350
|
||||
if (elo >= 1051) return 5 // 1051–1200
|
||||
if (elo >= 901) return 4 // 901–1050
|
||||
if (elo >= 751) return 3 // 751–900
|
||||
if (elo >= 501) return 2 // 501–750
|
||||
if (elo >= 100) return 1 // 100–500
|
||||
return 1 // alles darunter → Level 1
|
||||
}
|
||||
|
||||
export default function FaceitLevelImage({
|
||||
elo,
|
||||
level,
|
||||
size = 'md',
|
||||
className,
|
||||
title,
|
||||
alt,
|
||||
}: {
|
||||
elo?: number | null
|
||||
level?: number | null
|
||||
size?: Size
|
||||
className?: string
|
||||
title?: string
|
||||
alt?: string
|
||||
}) {
|
||||
const Lraw = (level ?? levelFromElo(elo))
|
||||
if (!Lraw) return null
|
||||
|
||||
// clamp safety
|
||||
const L = Math.max(1, Math.min(10, Math.round(Lraw)))
|
||||
|
||||
const dim = sizePx[size]
|
||||
const src = `/assets/img/icons/faceit/${L}.png`
|
||||
const label = title ?? `Faceit Skill Level ${L}`
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
width={dim}
|
||||
height={dim}
|
||||
alt={alt ?? label}
|
||||
title={label}
|
||||
className={['inline-block select-none', className].filter(Boolean).join(' ')}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ imageRendering: 'auto' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
22
src/app/[locale]/components/FaceitStat.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
// /src/app/components/FaceitStat.tsx
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import FaceitLevelImage from './FaceitLevelBadge'
|
||||
import FaceitElo from './FaceitElo'
|
||||
|
||||
export default function FaceitStat({
|
||||
level,
|
||||
elo,
|
||||
size = 'md',
|
||||
}: {
|
||||
level?: number | null
|
||||
elo?: number | null
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}) {
|
||||
return (
|
||||
<div className={['inline-flex items-center gap-2 px-2 py-1'].filter(Boolean).join(' ')}>
|
||||
<FaceitLevelImage elo={elo ?? undefined} size={size} className="-ml-0.5" />
|
||||
<FaceitElo elo={elo ?? undefined} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -24,6 +24,8 @@ type Props = {
|
||||
phase?: string
|
||||
score?: string
|
||||
inline?: boolean
|
||||
connectUri?: string
|
||||
missingCount?: number
|
||||
}
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
@ -74,6 +76,8 @@ export default function GameBanner(props: Props) {
|
||||
|
||||
const phaseStr = String(phase ?? 'unknown').toLowerCase()
|
||||
|
||||
const shownConnected = Math.max(0, connectedCount - (props.missingCount ?? 0))
|
||||
|
||||
// Ziel-Sichtbarkeit anhand Props/Viewport/Phase
|
||||
const targetShow = !isSmDown && visible && phaseStr !== 'unknown'
|
||||
|
||||
@ -131,7 +135,8 @@ export default function GameBanner(props: Props) {
|
||||
}
|
||||
|
||||
const openGame = () => {
|
||||
try { window.location.href = 'steam://rungameid/730' } catch {}
|
||||
const uri = props.connectUri || 'steam://rungameid/730'
|
||||
try { window.location.href = uri } catch {}
|
||||
}
|
||||
|
||||
const InfoRow = () => (
|
||||
@ -139,7 +144,7 @@ export default function GameBanner(props: Props) {
|
||||
<span>Map: <span className="font-semibold">{pretty.map}</span></span>
|
||||
<span>Phase: <span className="font-semibold">{pretty.phase}</span></span>
|
||||
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
|
||||
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
|
||||
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{shownConnected}</span> / {totalExpected}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@ -39,111 +39,50 @@ const fmtCountdown = (ms: number) => {
|
||||
return `${h}:${pad(m)}:${pad(s)}`
|
||||
}
|
||||
|
||||
// --- Winrate-Fetch & Normalizer ---------------------------------------------
|
||||
// --- Batch-Winrate (statt pro Spieler) --------------------------------------
|
||||
|
||||
// einfacher In-Memory-Cache pro Spieler
|
||||
const winrateCache = new Map<string, Record<string, number>>();
|
||||
// Cache pro Spieler (weiterverwenden)
|
||||
const winrateCache = new Map<string, Record<string, number>>()
|
||||
|
||||
/**
|
||||
* Normalisiert verschiedene mögliche API-Response-Shapes auf:
|
||||
* { "de_inferno": 53.2, "de_mirage": 47.8, ... } (Prozent 0..100)
|
||||
*/
|
||||
function normalizeWinrateResponse(
|
||||
raw: any,
|
||||
): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
type BatchByPlayer = Record<string, Record<string, number>> // steamId -> { mapKey -> pct 0..100 }
|
||||
|
||||
if (!raw) return out;
|
||||
/** Liest /api/user/winrate und normiert auf 0..100 (Float), NICHT ×10 */
|
||||
async function fetchWinratesBatch(
|
||||
steamIds: string[],
|
||||
opts?: { types?: string[]; onlyActive?: boolean }
|
||||
): Promise<BatchByPlayer> {
|
||||
const ids = Array.from(new Set(steamIds.filter(Boolean)));
|
||||
if (!ids.length) return {};
|
||||
|
||||
// 1) Bereits im Zielformat? -> { de_inferno: 53.2, ... }
|
||||
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const key = String(k).toLowerCase();
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[key] = v;
|
||||
} else if (v && typeof v === 'object') {
|
||||
// { winrate: 53.2 } oder { wins, losses }
|
||||
const winrate = (v as any).winrate;
|
||||
const wins = Number((v as any).wins ?? (v as any).w);
|
||||
const losses = Number((v as any).losses ?? (v as any).l);
|
||||
if (typeof winrate === 'number' && Number.isFinite(winrate)) {
|
||||
out[key] = winrate;
|
||||
} else if (Number.isFinite(wins) && Number.isFinite(losses) && wins + losses > 0) {
|
||||
out[key] = (wins / (wins + losses)) * 100;
|
||||
}
|
||||
}
|
||||
const q = new URLSearchParams();
|
||||
|
||||
// steamIds als CSV in EINEM Param
|
||||
q.set('steamIds', ids.join(','));
|
||||
|
||||
// types als wiederholte Parameter
|
||||
(opts?.types ?? []).forEach(t => q.append('types', t));
|
||||
|
||||
if (opts?.onlyActive === false) q.append('onlyActive', 'false');
|
||||
|
||||
const r = await fetch(`/api/user/winrate?${q.toString()}`, { cache: 'no-store' });
|
||||
if (!r.ok) return {};
|
||||
|
||||
const json = await r.json().catch(() => null);
|
||||
const out: BatchByPlayer = {};
|
||||
const byPlayer = json?.byPlayer ?? {};
|
||||
for (const [steamId, block] of Object.entries<any>(byPlayer)) {
|
||||
const maps = block?.byMap ?? {};
|
||||
const normalized: Record<string, number> = {};
|
||||
for (const [mapKey, agg] of Object.entries<any>(maps)) {
|
||||
const pctX10 = Number(agg?.pct);
|
||||
if (Number.isFinite(pctX10)) normalized[mapKey] = pctX10 / 10;
|
||||
}
|
||||
return out;
|
||||
out[steamId] = normalized;
|
||||
}
|
||||
|
||||
// 2) Array-Shapes:
|
||||
// a) [{ mapKey: "de_inferno", winrate: 51.2 }, ...]
|
||||
// b) [{ map: "de_inferno", wins: 23, losses: 19 }, ...]
|
||||
if (Array.isArray(raw)) {
|
||||
for (const row of raw) {
|
||||
if (!row) continue;
|
||||
const key = String(row.mapKey ?? row.map ?? row.key ?? '').toLowerCase();
|
||||
if (!key) continue;
|
||||
|
||||
if (typeof row.winrate === 'number' && Number.isFinite(row.winrate)) {
|
||||
out[key] = row.winrate;
|
||||
continue;
|
||||
}
|
||||
|
||||
const wins = Number(row.wins ?? row.W ?? row.w ?? 0);
|
||||
const losses = Number(row.losses ?? row.L ?? row.l ?? 0);
|
||||
if (Number.isFinite(wins) && Number.isFinite(losses) && wins + losses > 0) {
|
||||
out[key] = (wins / (wins + losses)) * 100;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const id of Object.keys(out)) winrateCache.set(id, out[id]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt Winrates pro Map für einen Spieler.
|
||||
* Erwartetes Ergebnis (nach Normalisierung):
|
||||
* { "de_inferno": 53.2, "de_mirage": 47.8, ... } (Prozent 0..100)
|
||||
*
|
||||
* ➜ Passe die URL-Reihenfolge unten an deine API an.
|
||||
*/
|
||||
async function fetchWinrate(steamId: string): Promise<Record<string, number>> {
|
||||
// Cache-Hit?
|
||||
const cached = winrateCache.get(steamId);
|
||||
if (cached) return cached;
|
||||
|
||||
// Kandidaten-Endpoints (der erste, der 200 liefert, wird genommen)
|
||||
const candidates = [
|
||||
`/api/user/${steamId}/winrate`,
|
||||
`/api/user/${steamId}/map-stats`,
|
||||
];
|
||||
|
||||
let normalized: Record<string, number> = {};
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) continue;
|
||||
const json = await r.json().catch(() => null);
|
||||
normalized = normalizeWinrateResponse(json);
|
||||
// Wenn irgendwas Sinnvolles kam, abbrechen:
|
||||
if (Object.keys(normalized).length) break;
|
||||
} catch {
|
||||
// nächste URL probieren
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (nix gefunden) -> leeres Objekt
|
||||
if (!Object.keys(normalized).length) {
|
||||
normalized = {};
|
||||
}
|
||||
|
||||
winrateCache.set(steamId, normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/* =================== Component =================== */
|
||||
|
||||
export default function MapVotePanel({ match }: Props) {
|
||||
@ -608,62 +547,103 @@ export default function MapVotePanel({ match }: Props) {
|
||||
// 2) State für Radar-Daten je Team + Team-Ø
|
||||
const [teamRadarLeft, setTeamRadarLeft] = useState<number[]>(activeMapKeys.map(() => 0))
|
||||
const [teamRadarRight, setTeamRadarRight] = useState<number[]>(activeMapKeys.map(() => 0))
|
||||
const [teamAvgLeft, setTeamAvgLeft] = useState<number>(0)
|
||||
const [teamAvgRight, setTeamAvgRight] = useState<number>(0)
|
||||
const lastFetchSigRef = useRef<string>('');
|
||||
|
||||
// 3) Laden & Aggregieren: Mittelwert pro Map über alle Spieler des Teams
|
||||
// Hilfs-Memos: eindeutige Id-Liste + Schlüssel
|
||||
const allSteamIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
playersLeft.forEach(p => ids.add(p.user.steamId));
|
||||
playersRight.forEach(p => ids.add(p.user.steamId));
|
||||
return Array.from(ids);
|
||||
}, [playersLeft, playersRight]);
|
||||
|
||||
// stabile Id-Menge über beide Teams (reihenfolgeunabhängig)
|
||||
const idsKey = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const p of playersLeft) s.add(p.user.steamId);
|
||||
for (const p of playersRight) s.add(p.user.steamId);
|
||||
return Array.from(s).sort().join(','); // <- sort macht's stabil
|
||||
}, [playersLeft, playersRight]);
|
||||
|
||||
// stabile Map-Menge (reihenfolgeunabhängig, nur Keys)
|
||||
const mapsKey = useMemo(
|
||||
() => Array.from(new Set(activeMapKeys)).sort().join(','),
|
||||
[activeMapKeys]
|
||||
)
|
||||
|
||||
// Labels nur aus keys ableiten → stabil solange mapsKey gleich bleibt
|
||||
const radarLabels = useMemo(() => {
|
||||
// nur MAP_OPTIONS / state.mapVisuals lesen, Ergebnis aber von mapsKey abhängig
|
||||
return activeMapKeys.map(labelOf)
|
||||
}, [mapsKey, labelOf, activeMapKeys])
|
||||
|
||||
// Datasets-Array: nur neu, wenn sich Werte oder Namen ändern
|
||||
const radarDatasets = useMemo(() => ([
|
||||
{
|
||||
label: teamRight?.name ?? 'Team Rechts',
|
||||
data: teamRadarRight,
|
||||
borderColor: 'rgba(239,68,68,0.95)',
|
||||
backgroundColor: 'rgba(239,68,68,0.18)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
{
|
||||
label: teamLeft?.name ?? 'Team Links',
|
||||
data: teamRadarLeft,
|
||||
borderColor: 'rgba(34,197,94,0.95)',
|
||||
backgroundColor: 'rgba(34,197,94,0.18)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
]), [teamRadarLeft, teamRadarRight, teamLeft?.name, teamRight?.name])
|
||||
|
||||
// Icons ebenfalls stabilisieren
|
||||
const radarIcons = useMemo(
|
||||
() => activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`),
|
||||
[mapsKey] // reicht, solange keys unverändert
|
||||
)
|
||||
|
||||
// Effect NUR an stabile Keys + Tab hängen
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadTeam(
|
||||
teamPlayers: MatchPlayer[],
|
||||
setterData: (arr: number[]) => void,
|
||||
setterAvg: (v: number) => void
|
||||
) {
|
||||
try {
|
||||
if (!teamPlayers.length) {
|
||||
setterData(activeMapKeys.map(() => 0))
|
||||
setterAvg(0)
|
||||
return
|
||||
}
|
||||
|
||||
// Alle Spieler parallel holen
|
||||
const perPlayer = await Promise.allSettled(
|
||||
teamPlayers.map(p => fetchWinrate(p.user.steamId))
|
||||
)
|
||||
|
||||
// Mittelwert pro MapKey bilden
|
||||
const mapAverages: number[] = activeMapKeys.map(k => {
|
||||
const vals: number[] = []
|
||||
perPlayer.forEach(res => {
|
||||
if (res.status === 'fulfilled' && typeof res.value[k] === 'number') {
|
||||
vals.push(res.value[k])
|
||||
}
|
||||
})
|
||||
return vals.length ? avg(vals) : 0
|
||||
})
|
||||
|
||||
// Team-Gesamtdurchschnitt: nur Maps berücksichtigen, für die es Daten gab (>0)
|
||||
const present = mapAverages.filter(v => v > 0)
|
||||
const teamAverage = present.length ? avg(present) : 0
|
||||
|
||||
if (!cancelled) {
|
||||
setterData(mapAverages.map(v => Math.round(v)))
|
||||
setterAvg(Math.round(teamAverage))
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setterData(activeMapKeys.map(() => 0))
|
||||
setterAvg(0)
|
||||
}
|
||||
}
|
||||
if (tab !== 'winrate') return;
|
||||
if (!idsKey || !mapsKey) {
|
||||
setTeamRadarLeft(activeMapKeys.map(() => 0));
|
||||
setTeamRadarRight(activeMapKeys.map(() => 0));
|
||||
return;
|
||||
}
|
||||
|
||||
loadTeam(playersLeft, setTeamRadarLeft, setTeamAvgLeft)
|
||||
loadTeam(playersRight, setTeamRadarRight, setTeamAvgRight)
|
||||
const sig = `${idsKey}|${mapsKey}|premier,competitive|onlyActive:true`;
|
||||
if (lastFetchSigRef.current === sig) return;
|
||||
lastFetchSigRef.current = sig;
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [playersLeft, playersRight, activeMapKeys])
|
||||
let aborted = false;
|
||||
(async () => {
|
||||
try {
|
||||
const ids = idsKey.split(',');
|
||||
const batch = await fetchWinratesBatch(ids, { types: ['premier','competitive'], onlyActive: true });
|
||||
|
||||
const avgForTeam = (teamPlayers: MatchPlayer[]) =>
|
||||
activeMapKeys.map(k => {
|
||||
const vals:number[] = [];
|
||||
for (const p of teamPlayers) {
|
||||
const v = batch[p.user.steamId]?.[k] ?? winrateCache.get(p.user.steamId)?.[k] ?? 0;
|
||||
if (Number.isFinite(v) && v > 0) vals.push(v);
|
||||
}
|
||||
return vals.length ? Math.round(vals.reduce((a,b)=>a+b,0)/vals.length) : 0;
|
||||
});
|
||||
|
||||
if (!aborted) {
|
||||
setTeamRadarLeft(avgForTeam(playersLeft));
|
||||
setTeamRadarRight(avgForTeam(playersRight));
|
||||
}
|
||||
} catch {
|
||||
if (!aborted) {
|
||||
setTeamRadarLeft(activeMapKeys.map(() => 0));
|
||||
setTeamRadarRight(activeMapKeys.map(() => 0));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { aborted = true; };
|
||||
}, [tab, idsKey, mapsKey]);
|
||||
|
||||
/* =================== Render =================== */
|
||||
|
||||
@ -1031,28 +1011,13 @@ export default function MapVotePanel({ match }: Props) {
|
||||
<div className="relative w-full max-w-full md:max-w-xl xl:max-w-2xl aspect-[4/3] md:aspect-[16/10] rounded-xl border border-neutral-700 bg-neutral-900/40 p-4">
|
||||
<Chart
|
||||
type="radar"
|
||||
labels={activeMapLabels}
|
||||
labels={radarLabels}
|
||||
height="auto"
|
||||
datasets={[
|
||||
{
|
||||
label: teamRight?.name ?? 'Team Rechts',
|
||||
data: teamRadarRight,
|
||||
borderColor: 'rgba(239,68,68,0.95)',
|
||||
backgroundColor: 'rgba(239,68,68,0.18)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
{
|
||||
label: teamLeft?.name ?? 'Team Links',
|
||||
data: teamRadarLeft,
|
||||
borderColor: 'rgba(34,197,94,0.95)',
|
||||
backgroundColor: 'rgba(34,197,94,0.18)',
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
radarIcons={activeMapKeys.map(k => `/assets/img/mapicons/map_icon_${k}.svg`)}
|
||||
datasets={radarDatasets}
|
||||
radarIcons={radarIcons}
|
||||
radarIconSize={40}
|
||||
radarHideTicks={true}
|
||||
radarIconLabels={true}
|
||||
radarHideTicks
|
||||
radarIconLabels
|
||||
radarIconLabelFont="12px Inter, system-ui, sans-serif"
|
||||
radarIconLabelColor="#ffffff"
|
||||
radarMax={100}
|
||||
|
||||
@ -24,6 +24,73 @@ import Alert from './Alert'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import Card from './Card'
|
||||
import MiniPlayerCard from './MiniPlayerCard'
|
||||
import type { PlayerSummary } from './MiniPlayerCard'
|
||||
|
||||
// Für den Prefetch greifen wir dieselbe Performance-Logik auf:
|
||||
const KD_CAP = 2.0
|
||||
const ADR_CAP = 120
|
||||
const KPR_CAP = 1.2
|
||||
const APR_CAP = 0.6
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||
|
||||
type ApiStats = {
|
||||
stats: Array<{
|
||||
date: string
|
||||
kills: number
|
||||
deaths: number
|
||||
assists?: number | null
|
||||
totalDamage?: number | null
|
||||
rounds?: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
function perfOfMatchPrefetch(m: { kills?: number; deaths?: number; assists?: number|null; totalDamage?: number|null; rounds?: number|null }) {
|
||||
const k = m.kills ?? 0
|
||||
const d = m.deaths ?? 0
|
||||
const a = m.assists ?? 0
|
||||
const r = Math.max(1, m.rounds ?? 0)
|
||||
const kd = d > 0 ? k / d : KD_CAP
|
||||
const adr = (m.totalDamage ?? 0) / r
|
||||
const kpr = k / r
|
||||
const apr = a / r
|
||||
const kdS = clamp01(kd / KD_CAP)
|
||||
const adrS = clamp01(adr / ADR_CAP)
|
||||
const kprS = clamp01(kpr / KPR_CAP)
|
||||
const aprS = clamp01(apr / APR_CAP)
|
||||
return 0.45 * kdS + 0.45 * adrS + 0.10 * (0.7 * kprS + 0.3 * aprS)
|
||||
}
|
||||
|
||||
async function buildPlayerSummary(steamId: string): Promise<PlayerSummary | null> {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
||||
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store', credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as ApiStats
|
||||
const matches = Array.isArray(data?.stats) ? data.stats : []
|
||||
|
||||
const games = matches.length
|
||||
const kills = matches.reduce((s, m) => s + (m.kills ?? 0), 0)
|
||||
const deaths = matches.reduce((s, m) => s + (m.deaths ?? 0), 0)
|
||||
const dmg = matches.reduce((s, m) => s + (m.totalDamage ?? 0), 0)
|
||||
const kd = deaths > 0 ? kills / deaths : Infinity
|
||||
const avgDmgPerMatch = games ? dmg / games : 0
|
||||
const avgKillsPerMatch = games ? kills / games : 0
|
||||
|
||||
const sorted = [...matches].sort((a,b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
const last10 = sorted.slice(-10)
|
||||
const perfSeries = last10.length ? last10.map(perfOfMatchPrefetch) : [0,0]
|
||||
const lastPerf = perfSeries.at(-1) ?? 0
|
||||
const prevPerf = perfSeries.length > 1
|
||||
? perfSeries.slice(0, -1).reduce((a,b)=>a+b,0) / (perfSeries.length - 1)
|
||||
: lastPerf
|
||||
const perfDelta = perfSeries.length > 1 ? (lastPerf - prevPerf) : 0
|
||||
|
||||
return { games, kd, avgDmgPerMatch, avgKillsPerMatch, perfDelta, perfSeries }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
|
||||
|
||||
@ -280,10 +347,12 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
const router = useRouter()
|
||||
const isAdmin = !!session?.user?.isAdmin
|
||||
const [userTZ, setUserTZ] = useState<string>('Europe/Berlin')
|
||||
const [playerSummaries, setPlayerSummaries] = useState<Record<string, PlayerSummary | null>>({})
|
||||
|
||||
// ⬇️ bestOf nur im State halten: community → 3 (oder was du magst), sonst 1
|
||||
const [bestOf, setBestOf] = useState<1 | 3 | 5>(() =>
|
||||
match.matchType === 'community' ? 3 : 1
|
||||
)
|
||||
const [bestOf, setBestOf] = useState<1 | 3 | 5>(() =>
|
||||
match.matchType === 'community' ? 3 : 1
|
||||
)
|
||||
|
||||
// Alle Maps der Serie (BO3/BO5) – abhängig von bestOf-State
|
||||
const allMaps = useMemo(
|
||||
@ -296,6 +365,12 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
const [now, setNow] = useState(initialNow)
|
||||
const [editMetaOpen, setEditMetaOpen] = useState(false)// Modal-State
|
||||
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
||||
|
||||
const [hoverPlayer, setHoverPlayer] = useState<MatchPlayer | null>(null)
|
||||
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null)
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||
const cardElRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Mapvote Overrides (SSE-reaktiv)
|
||||
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
|
||||
@ -328,6 +403,78 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
|
||||
const currentMapKey = normalizeMapKey(match.map)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hoverPlayer) return
|
||||
|
||||
const isInCorridor = (x: number, y: number, a: DOMRect, c: DOMRect, pad = 4) => {
|
||||
// gemeinsame vertikale Ausdehnung
|
||||
const top = Math.min(a.top, c.top) - pad
|
||||
const bottom = Math.max(a.bottom, c.bottom) + pad
|
||||
|
||||
// horizontaler Korridor zwischen rechter Ankerkante und linker Kartenkante (oder umgekehrt)
|
||||
const left = Math.min(a.right, c.left) - pad
|
||||
const right = Math.max(a.right, c.left) + pad
|
||||
|
||||
return x >= left && x <= right && y >= top && y <= bottom
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const t = e.target as Node | null
|
||||
const cardEl = cardElRef.current
|
||||
const aEl = anchorEl
|
||||
|
||||
if (!t || !cardEl || !aEl) return
|
||||
|
||||
// 1) Cursor in Card oder Anchor? → offen lassen
|
||||
if (cardEl.contains(t) || aEl.contains(t)) return
|
||||
|
||||
// 2) Im Korridor zwischen beiden?
|
||||
const a = aEl.getBoundingClientRect()
|
||||
const c = cardEl.getBoundingClientRect()
|
||||
if (isInCorridor(e.clientX, e.clientY, a, c, 6)) return
|
||||
|
||||
// 3) sonst schließen
|
||||
setHoverPlayer(null)
|
||||
setHoverRect(null)
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: true })
|
||||
return () => window.removeEventListener('pointermove', onPointerMove)
|
||||
}, [hoverPlayer, anchorEl])
|
||||
|
||||
useEffect(() => {
|
||||
// alle SteamIDs aus beiden Teams
|
||||
const ids = [...(match.teamA?.players ?? []), ...(match.teamB?.players ?? [])]
|
||||
.map(p => p.user?.steamId)
|
||||
.filter((id): id is string => !!id)
|
||||
|
||||
if (ids.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
;(async () => {
|
||||
// nur noch laden, was wir noch nicht haben
|
||||
const idsToFetch = ids.filter(id => !(id in playerSummaries))
|
||||
if (idsToFetch.length === 0) return
|
||||
|
||||
const results = await Promise.all(idsToFetch.map(async (id) => {
|
||||
const summary = await buildPlayerSummary(id)
|
||||
return [id, summary] as const
|
||||
}))
|
||||
|
||||
if (cancelled) return
|
||||
setPlayerSummaries(prev => {
|
||||
const next = { ...prev }
|
||||
for (const [id, sum] of results) next[id] = sum
|
||||
return next
|
||||
})
|
||||
})()
|
||||
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [match.teamA?.players, match.teamB?.players])
|
||||
|
||||
// beim mount user-tz aus DB laden
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
@ -532,6 +679,21 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
<Table.Cell
|
||||
className={`flex items-center`}
|
||||
hoverable
|
||||
onMouseEnter={(e) => {
|
||||
setHoverPlayer(p)
|
||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
||||
setAnchorEl(e.currentTarget as HTMLElement)
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (hoverPlayer?.user.steamId === p.user.steamId) {
|
||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
||||
}
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
setHoverPlayer(p)
|
||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
||||
setAnchorEl(e.currentTarget as HTMLElement)
|
||||
}}
|
||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||
>
|
||||
<img
|
||||
@ -851,6 +1013,18 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hoverPlayer && (
|
||||
<MiniPlayerCard
|
||||
open={!!hoverPlayer}
|
||||
player={hoverPlayer}
|
||||
anchor={hoverRect}
|
||||
onClose={() => { setHoverPlayer(null); setHoverRect(null); setAnchorEl(null) }}
|
||||
prefetchedSummary={hoverPlayer.user?.steamId ? playerSummaries[hoverPlayer.user.steamId] ?? null : null}
|
||||
anchorEl={anchorEl}
|
||||
onCardMount={(el) => { cardElRef.current = el }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
464
src/app/[locale]/components/MiniPlayerCard.tsx
Normal file
@ -0,0 +1,464 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Link from 'next/link'
|
||||
import type { MatchPlayer } from '../../../types/match'
|
||||
import PremierRankBadge from './PremierRankBadge'
|
||||
import CompRankBadge from './CompRankBadge'
|
||||
|
||||
export type MiniPlayerCardProps = {
|
||||
open: boolean
|
||||
player: MatchPlayer
|
||||
anchor: DOMRect | null
|
||||
onClose?: () => void
|
||||
prefetchedSummary?: PlayerSummary | null
|
||||
/** echtes Anchor-Element (für Hover-Containment im Parent) */
|
||||
anchorEl?: HTMLElement | null
|
||||
/** Card-Element an Parent melden */
|
||||
onCardMount?: (el: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
type ApiStats = {
|
||||
stats: Array<{
|
||||
date: string
|
||||
kills: number
|
||||
deaths: number
|
||||
assists?: number | null
|
||||
totalDamage?: number | null
|
||||
rounds?: number | null
|
||||
}>
|
||||
}
|
||||
|
||||
type UserWithFaceit = {
|
||||
steamId?: string | null
|
||||
name?: string | null
|
||||
avatar?: string | null
|
||||
premierRank?: number | null
|
||||
// Ban (flat – wie in ProfileHeader)
|
||||
vacBanned?: boolean | null
|
||||
numberOfVACBans?: number | null
|
||||
numberOfGameBans?: number | null
|
||||
communityBanned?: boolean | null
|
||||
economyBan?: string | null
|
||||
daysSinceLastBan?: number | null
|
||||
// FACEIT (flat – wie in ProfileHeader)
|
||||
faceitNickname?: string | null
|
||||
faceitUrl?: string | null
|
||||
faceitLevel?: number | null
|
||||
faceitElo?: number | null
|
||||
}
|
||||
|
||||
/** gleiche Struktur wie in MatchDetails */
|
||||
export type PlayerSummary = {
|
||||
games: number
|
||||
kd: number
|
||||
avgDmgPerMatch: number
|
||||
avgKillsPerMatch: number
|
||||
perfDelta: number
|
||||
perfSeries: number[]
|
||||
}
|
||||
|
||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||
|
||||
// Heuristische Caps
|
||||
const KD_CAP = 2.0
|
||||
const ADR_CAP = 120
|
||||
const KPR_CAP = 1.2
|
||||
const APR_CAP = 0.6
|
||||
|
||||
function perfOfMatch(m: { kills?: number; deaths?: number; assists?: number | null; totalDamage?: number | null; rounds?: number | null }) {
|
||||
const k = m.kills ?? 0
|
||||
const d = m.deaths ?? 0
|
||||
const a = m.assists ?? 0
|
||||
const r = Math.max(1, m.rounds ?? 0)
|
||||
|
||||
const kd = d > 0 ? k / d : KD_CAP
|
||||
const adr = (m.totalDamage ?? 0) / r
|
||||
const kpr = k / r
|
||||
const apr = a / r
|
||||
|
||||
const kdS = clamp01(kd / KD_CAP)
|
||||
const adrS = clamp01(adr / ADR_CAP)
|
||||
const kprS = clamp01(kpr / KPR_CAP)
|
||||
const aprS = clamp01(apr / APR_CAP)
|
||||
|
||||
// Gewichtung: 45% KD, 45% ADR, 10% Impact (KPR 70% / APR 30%)
|
||||
return 0.45 * kdS + 0.45 * adrS + 0.10 * (0.7 * kprS + 0.3 * aprS) // 0..1
|
||||
}
|
||||
|
||||
/** kleine Sparkline (Performance) */
|
||||
function Sparkline({ values }: { values: number[] }) {
|
||||
const W = 200, H = 40, pad = 6
|
||||
const n = Math.max(1, values.length)
|
||||
const max = Math.max(...values, 1), min = Math.min(...values, 0), range = Math.max(0.05, max - min)
|
||||
const step = (W - pad * 2) / Math.max(1, n - 1)
|
||||
const pts = values.map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`).join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-[200px] h-[40px] text-blue-300/90">
|
||||
<polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.95" strokeWidth="2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MiniPlayerCard({
|
||||
open, player, anchor, onClose, prefetchedSummary, anchorEl, onCardMount
|
||||
}: MiniPlayerCardProps) {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Position-State
|
||||
const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({
|
||||
top: 0, left: 0, side: 'right'
|
||||
})
|
||||
|
||||
// Profil-Summary/SteamId + Lade-State
|
||||
const u = (player.user ?? {}) as UserWithFaceit
|
||||
const steam64 = u.steamId ?? null
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [summary, setSummary] = useState<PlayerSummary | null>(prefetchedSummary ?? null)
|
||||
|
||||
// FACEIT-Werte direkt aus user-Props (wie im ProfileHeader)
|
||||
const faceitLevel = u.faceitLevel ?? null
|
||||
const faceitElo = u.faceitElo ?? null
|
||||
const faceitNick = u.faceitNickname ?? null
|
||||
const faceitUrl = u.faceitUrl
|
||||
? u.faceitUrl.replace('{lang}', 'en')
|
||||
: (faceitNick ? `https://www.faceit.com/en/players/${encodeURIComponent(faceitNick)}` : null)
|
||||
|
||||
// Outside-Click + ESC schließen
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose?.() }
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!cardRef.current) return
|
||||
if (!cardRef.current.contains(e.target as Node)) onClose?.()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
const setCardRef = (el: HTMLDivElement | null) => {
|
||||
cardRef.current = el
|
||||
onCardMount?.(el) // Parent informieren
|
||||
if (open) schedulePosition()
|
||||
}
|
||||
|
||||
// Stats laden, wenn kein Prefetch
|
||||
useEffect(() => {
|
||||
if (!open || !steam64) return
|
||||
if (prefetchedSummary) { setSummary(prefetchedSummary); setLoading(false); return }
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setSummary(null)
|
||||
const base = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
||||
const res = await fetch(`${base}/api/stats/${steam64}`, { cache: 'no-store', signal: ctrl.signal })
|
||||
if (!res.ok) return
|
||||
const data = (await res.json()) as ApiStats
|
||||
const matches = Array.isArray(data?.stats) ? data.stats : []
|
||||
|
||||
const games = matches.length
|
||||
const kills = matches.reduce((s, m) => s + (m.kills ?? 0), 0)
|
||||
const deaths = matches.reduce((s, m) => s + (m.deaths ?? 0), 0)
|
||||
const dmg = matches.reduce((s, m) => s + (m.totalDamage ?? 0), 0)
|
||||
const kd = deaths > 0 ? kills / deaths : Infinity
|
||||
const avgDmgPerMatch = games ? dmg / games : 0
|
||||
const avgKillsPerMatch = games ? kills / games : 0
|
||||
|
||||
const sorted = [...matches].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
const last10 = sorted.slice(-10)
|
||||
const perfSeries = last10.length ? last10.map(perfOfMatch) : [0, 0]
|
||||
const lastPerf = perfSeries.at(-1) ?? 0
|
||||
const prevPerf = perfSeries.length > 1
|
||||
? perfSeries.slice(0, -1).reduce((a, b) => a + b, 0) / (perfSeries.length - 1)
|
||||
: lastPerf
|
||||
const perfDelta = perfSeries.length > 1 ? (lastPerf - prevPerf) : 0
|
||||
|
||||
setSummary({ games, kd, avgDmgPerMatch, avgKillsPerMatch, perfDelta, perfSeries })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const t = setTimeout(load, 60) // kleiner Delay, um Hover-Flatter zu vermeiden
|
||||
return () => { clearTimeout(t); ctrl.abort() }
|
||||
}, [open, steam64, prefetchedSummary])
|
||||
|
||||
// ----- Positionierung (0px Gap) -----
|
||||
const schedulePosition = () => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(position))
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (open) schedulePosition()
|
||||
}, [open, anchor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onScrollOrResize = () => schedulePosition()
|
||||
window.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
}
|
||||
}, [open, anchor])
|
||||
|
||||
function position() {
|
||||
if (!anchor || !cardRef.current) return
|
||||
const cardEl = cardRef.current
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
const prevVis = cardEl.style.visibility
|
||||
cardEl.style.visibility = 'hidden'
|
||||
const { width: cw, height: ch } = cardEl.getBoundingClientRect()
|
||||
|
||||
// KEIN GAP: direkt an die Table-Cell andocken
|
||||
const rightLeft = anchor.right
|
||||
const leftLeft = anchor.left - cw
|
||||
const fitsRight = rightLeft + cw <= vw
|
||||
const fitsLeft = leftLeft >= 8
|
||||
const side: 'right' | 'left' = fitsRight || !fitsLeft ? 'right' : 'left'
|
||||
const left = side === 'right' ? Math.min(rightLeft, vw - cw - 8) : Math.max(8, leftLeft)
|
||||
|
||||
const topRaw = anchor.top + (anchor.height - ch) / 2
|
||||
const top = clamp(topRaw, 8, vh - ch - 8)
|
||||
|
||||
setPos({ top: Math.round(top), left: Math.round(left), side })
|
||||
cardEl.style.visibility = prevVis
|
||||
}
|
||||
|
||||
// ----- BAN-Badges -----
|
||||
// 1) bevorzugt altes verschachteltes Feld (falls vorhanden)
|
||||
const nestedBan = (player.user as any)?.banStatus
|
||||
// 2) sonst auf flache User-Felder zurückfallen (wie im ProfileHeader)
|
||||
const flat = u
|
||||
|
||||
const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0)
|
||||
const isBannedNested =
|
||||
!!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 ||
|
||||
(nestedBan?.numberOfGameBans ?? 0) > 0 || nestedBan?.communityBanned ||
|
||||
(nestedBan?.economyBan && nestedBan.economyBan !== 'none'))
|
||||
|
||||
const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0
|
||||
const isBannedFlat =
|
||||
!!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 || (flat.numberOfGameBans ?? 0) > 0 ||
|
||||
!!flat.communityBanned || (!!flat.economyBan && flat.economyBan !== 'none')
|
||||
|
||||
const hasVac = nestedBan ? hasVacNested : hasVacFlat
|
||||
const isBanned = nestedBan ? isBannedNested : isBannedFlat
|
||||
|
||||
const banTooltip = useMemo(() => {
|
||||
const parts: string[] = []
|
||||
if (nestedBan) {
|
||||
if (nestedBan.vacBanned) parts.push('VAC-Ban aktiv')
|
||||
if ((nestedBan.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${nestedBan.numberOfVACBans}`)
|
||||
if ((nestedBan.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${nestedBan.numberOfGameBans}`)
|
||||
if (nestedBan.communityBanned) parts.push('Community-Ban')
|
||||
if (nestedBan.economyBan && nestedBan.economyBan !== 'none') parts.push(`Economy: ${nestedBan.economyBan}`)
|
||||
if (typeof nestedBan.daysSinceLastBan === 'number') parts.push(`Tage seit letztem Ban: ${nestedBan.daysSinceLastBan}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (flat.vacBanned) parts.push('VAC-Ban aktiv')
|
||||
if ((flat.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${flat.numberOfVACBans}`)
|
||||
if ((flat.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${flat.numberOfGameBans}`)
|
||||
if (flat.communityBanned) parts.push('Community-Ban')
|
||||
if (flat.economyBan && flat.economyBan !== 'none') parts.push(`Economy: ${flat.economyBan}`)
|
||||
if (typeof flat.daysSinceLastBan === 'number') parts.push(`Tage seit letztem Ban: ${flat.daysSinceLastBan}`)
|
||||
return parts.join(' · ')
|
||||
}, [nestedBan, flat])
|
||||
|
||||
if (!open || typeof window === 'undefined') return null
|
||||
|
||||
const rankChange = typeof player.stats?.rankChange === 'number' ? player.stats!.rankChange! : null
|
||||
|
||||
// Links zu Steam/Faceit
|
||||
const steamUrl = steam64 ? `https://steamcommunity.com/profiles/${steam64}` : null
|
||||
|
||||
// FACEIT-Badge
|
||||
const FaceitBadge = () => {
|
||||
if (!faceitLevel && !faceitElo && !faceitNick) return null
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold ring-1 ring-inset ring-white/10 bg-white/8"
|
||||
title={[
|
||||
'FACEIT',
|
||||
faceitLevel ? `Lvl ${faceitLevel}` : null,
|
||||
faceitElo ? `ELO ${faceitElo}` : null,
|
||||
faceitNick ? `(${faceitNick})` : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" aria-hidden>
|
||||
<path d="M2 12l6 6 14-14" fill="none" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
<span>FACEIT {faceitLevel ? `L${faceitLevel}` : ''}{faceitElo ? ` • ${faceitElo}` : ''}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const body = (
|
||||
<div
|
||||
ref={setCardRef}
|
||||
role="dialog"
|
||||
aria-label={`Spielerinfo ${u.name ?? ''}`}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-auto fixed z-[10000] w-[320px] rounded-lg border border-white/10 bg-neutral-900/95 p-3 text-white shadow-2xl backdrop-blur"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
// WICHTIG: kein onMouseLeave/onBlur → Card bleibt zum Klicken offen
|
||||
>
|
||||
{/* Actions oben rechts (Steam & FACEIT) */}
|
||||
<div className="absolute right-2 top-2 flex items-center gap-1.5">
|
||||
{steamUrl && (
|
||||
<Link
|
||||
href={steamUrl}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label="Steam-Profil öffnen"
|
||||
title={`Steam-Profil von ${u.name ?? ''}`}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
|
||||
>
|
||||
<i className="fab fa-steam text-[16px]" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
{faceitUrl && (
|
||||
<Link
|
||||
href={faceitUrl}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label="FACEIT-Profil öffnen"
|
||||
title={`Faceit-Profil${faceitNick ? ` von ${faceitNick}` : ''}`}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
|
||||
>
|
||||
<img src="/assets/img/logos/faceit.svg" alt="" className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pfeil */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={[
|
||||
'absolute h-3 w-3 rotate-45 border border-white/10 bg-neutral-900/95',
|
||||
pos.side === 'right' ? '-left-1.5' : '-right-1.5'
|
||||
].join(' ')}
|
||||
style={{ top: 'calc(50% - 6px)' }}
|
||||
/>
|
||||
|
||||
{/* Header: Avatar + Name + Rank + BAN + FACEIT */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={u.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
||||
alt={u.name || 'Avatar'}
|
||||
className="h-12 w-12 rounded-full ring-1 ring-white/15"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
{/* Name → eigenes Profil */}
|
||||
<div className="truncate text-sm font-semibold">
|
||||
<Link href={steam64 ? `/profile/${steam64}` : '#'} className="hover:underline">
|
||||
{u.name ?? 'Unbekannt'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{/* Rank */}
|
||||
{typeof (u.premierRank ?? player.stats?.rankNew) === 'number' ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<PremierRankBadge rank={u.premierRank ?? player.stats?.rankNew ?? 0} />
|
||||
{rankChange !== null && (
|
||||
<span className={[
|
||||
'text-[11px] tabular-nums font-semibold',
|
||||
rankChange > 0 ? 'text-emerald-300' : rankChange < 0 ? 'text-rose-300' : 'text-neutral-300'
|
||||
].join(' ')}>
|
||||
{rankChange > 0 ? '+' : ''}{rankChange}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CompRankBadge rank={player.stats?.rankNew ?? 0} />
|
||||
)}
|
||||
|
||||
{/* FACEIT */}
|
||||
<FaceitBadge />
|
||||
|
||||
{/* BAN */}
|
||||
{isBanned && (
|
||||
<span
|
||||
title={banTooltip}
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-bold"
|
||||
style={{ background: hasVac ? 'rgba(220,38,38,.9)' : 'rgba(234,88,12,.9)' }}
|
||||
>
|
||||
{hasVac ? 'VAC' : 'BAN'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-3 h-px bg-white/10" />
|
||||
|
||||
{/* Mini-Profil */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="text-xs uppercase tracking-wide text-white/60">Profil</div>
|
||||
<Link
|
||||
href={steam64 ? `/profile/${steam64}` : '#'}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 hover:underline"
|
||||
>
|
||||
Profil öffnen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading && !summary && <div className="text-xs text-white/60">Lade…</div>}
|
||||
|
||||
{summary && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Chip label="Matches" value={fmtInt(summary.games)} />
|
||||
<Chip label="K/D" value={kdText(summary.kd)} />
|
||||
<Chip label="Ø Dmg/Match" value={fmtInt(summary.avgDmgPerMatch)} />
|
||||
<Chip label="Ø Kills/Match" value={fmtInt(summary.avgKillsPerMatch)} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-white/5 ring-1 ring-white/10 px-2 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] uppercase tracking-wide text-white/60">Performance</div>
|
||||
<div className={[
|
||||
'text-[11px] font-medium',
|
||||
summary.perfDelta > 0 ? 'text-emerald-300' : summary.perfDelta < 0 ? 'text-rose-300' : 'text-neutral-300'
|
||||
].join(' ')}>
|
||||
{summary.perfDelta === 0 ? '±0.00' : `${summary.perfDelta > 0 ? '+' : ''}${summary.perfDelta.toFixed(2)}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<Sparkline values={summary.perfSeries.length >= 2 ? summary.perfSeries : [0, 0]} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(body, document.body)
|
||||
}
|
||||
|
||||
/* --- kleine UI-Bausteine --- */
|
||||
function Chip({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-white/5 px-2 py-1 ring-1 ring-white/10">
|
||||
<div className="text-[10px] uppercase tracking-wide text-white/60">{label}</div>
|
||||
<div className="text-sm font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─ helpers ─ */
|
||||
const nf = new Intl.NumberFormat('de-DE')
|
||||
const fmtInt = (n: number) => nf.format(Math.round(n))
|
||||
const kdText = (v: number) => (v === Infinity ? '∞' : v.toFixed(2))
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/[locale]/components/TelemetrySocket.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@ -95,6 +97,7 @@ export default function TelemetrySocket() {
|
||||
const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null })
|
||||
|
||||
// connect uri + server name
|
||||
const [serverId, setServerId] = useState<string | null>(null)
|
||||
const [connectHref, setConnectHref] = useState<string | null>(null)
|
||||
const [serverName, setServerName] = useState<string | null>(null)
|
||||
|
||||
@ -112,13 +115,13 @@ export default function TelemetrySocket() {
|
||||
|
||||
// connect href from API
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
(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)
|
||||
}
|
||||
if (!r.ok) return
|
||||
const j = await r.json()
|
||||
if (j.connectHref) setConnectHref(j.connectHref)
|
||||
if (j.serverId) setServerId(j.serverId)
|
||||
} catch {}
|
||||
})()
|
||||
}, [])
|
||||
@ -294,7 +297,7 @@ export default function TelemetrySocket() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
body: JSON.stringify({ command: cmd, serverId: serverId ?? undefined }),
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// /src/app/components/profile/[steamId]/Profile.tsx
|
||||
import Link from 'next/link'
|
||||
import Card from '../../Card'
|
||||
import PremierRankBadge from '../../PremierRankBadge'
|
||||
@ -40,6 +39,28 @@ async function getStats(steamId: string): Promise<ApiStats | null> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/* Performance-Helper */
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||
const KD_CAP = 2.0
|
||||
const ADR_CAP = 120
|
||||
const KPR_CAP = 1.2
|
||||
const APR_CAP = 0.6
|
||||
function perfOfMatch(m: { kills?: number; deaths?: number; assists?: number|null; totalDamage?: number|null; rounds?: number|null }) {
|
||||
const k = m.kills ?? 0
|
||||
const d = m.deaths ?? 0
|
||||
const a = m.assists ?? 0
|
||||
const r = Math.max(1, m.rounds ?? 0)
|
||||
const kd = d > 0 ? k / d : KD_CAP
|
||||
const adr = (m.totalDamage ?? 0) / r
|
||||
const kpr = k / r
|
||||
const apr = a / r
|
||||
const kdS = clamp01(kd / KD_CAP)
|
||||
const adrS = clamp01(adr / ADR_CAP)
|
||||
const kprS = clamp01(kpr / KPR_CAP)
|
||||
const aprS = clamp01(apr / APR_CAP)
|
||||
return 0.45 * kdS + 0.45 * adrS + 0.10 * (0.7 * kprS + 0.3 * aprS)
|
||||
}
|
||||
|
||||
/* ───────── kleine UI-Bausteine (KPIs oben) ───────── */
|
||||
function Chip({
|
||||
label, value, icon, className = '',
|
||||
@ -268,34 +289,19 @@ export default async function Profile({ steamId }: Props) {
|
||||
const kdVal = kdRaw(kills, deaths)
|
||||
const kdClass = kdTone(kdVal)
|
||||
|
||||
|
||||
const KD_CAP = 5.0; // max. Wert für K/D im Form-Chart
|
||||
|
||||
// 1) zeitlich (alt -> neu) sortieren, damit wir die "letzten" sicher erwischen
|
||||
// Performance-Serie statt KD-Trend (letzte 12)
|
||||
const sortedByDate = [...matches].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
)
|
||||
|
||||
// 2) letzte N Spiele holen (z.B. 12)
|
||||
const last = sortedByDate.slice(-12)
|
||||
const perfSeries = last.map(perfOfMatch)
|
||||
const lastPerf = perfSeries.at(-1) ?? 0
|
||||
const prevPerf = perfSeries.length > 1
|
||||
? perfSeries.slice(0, -1).reduce((a, b) => a + b, 0) / (perfSeries.length - 1)
|
||||
: lastPerf
|
||||
const deltaPerf = perfSeries.length > 1 ? lastPerf - prevPerf : 0
|
||||
|
||||
// 3) stabile K/D-Serie erzeugen (Infinity abfangen & deckeln)
|
||||
const kdSeries = last.map(m => {
|
||||
const k = m.kills ?? 0
|
||||
const d = m.deaths ?? 0
|
||||
const v = d > 0 ? k / d : KD_CAP
|
||||
return Math.min(KD_CAP, Math.max(0, v))
|
||||
})
|
||||
|
||||
// 4) Delta berechnen (letzter Punkt vs. Ø der vorherigen)
|
||||
const lastKD = kdSeries.at(-1) ?? 0
|
||||
const prevKD = kdSeries.length > 1
|
||||
? kdSeries.slice(0, -1).reduce((a, b) => a + b, 0) / (kdSeries.length - 1)
|
||||
: lastKD
|
||||
const deltaKD = kdSeries.length > 1 ? lastKD - prevKD : 0
|
||||
|
||||
// 5) Sparkline robust füttern
|
||||
const sparkValues = kdSeries.length >= 2 ? kdSeries : [0, 0]
|
||||
const sparkValues = perfSeries.length >= 2 ? perfSeries : [0, 0]
|
||||
|
||||
const avgDmgPerMatch = games ? damage / games : 0
|
||||
const avgKillsPerMatch = games ? kills / games : 0
|
||||
@ -320,12 +326,12 @@ export default async function Profile({ steamId }: Props) {
|
||||
<div className={['rounded-md px-2 py-1 text-sm font-semibold ring-1 ring-inset', kdClass].join(' ')}>Ø K/D {kdTxt(kills,deaths)}</div>
|
||||
<div className={[
|
||||
'rounded-md px-2 py-1 text-xs ring-1 ring-inset',
|
||||
deltaKD >= 0
|
||||
deltaPerf >= 0
|
||||
? 'text-emerald-300 bg-emerald-500/10 ring-emerald-400/20'
|
||||
: 'text-rose-300 bg-rose-500/10 ring-rose-400/20',
|
||||
].join(' ')}>
|
||||
{kdSeries.length > 1 ? (deltaKD >= 0 ? '▲' : '▼') : '–'}{' '}
|
||||
{kdSeries.length > 1 ? Math.abs(deltaKD).toFixed(2) : '—'}
|
||||
{perfSeries.length > 1 ? (deltaPerf >= 0 ? '▲' : '▼') : '–'}{' '}
|
||||
{perfSeries.length > 1 ? Math.abs(deltaPerf).toFixed(2) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-blue-400">
|
||||
|
||||
@ -41,13 +41,12 @@ const tone = {
|
||||
orangeBg: 'rgba(255,159,64,.16)',
|
||||
}
|
||||
|
||||
/** formatiert ADR mit 1 Nachkommastelle */
|
||||
/** ADR hübsch */
|
||||
const fmtADR = (v: number) =>
|
||||
new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v)
|
||||
|
||||
/** robust: hole Rundenzahl aus MatchStats */
|
||||
/** Runden robust ziehen */
|
||||
function getRounds(m: Partial<MatchStats>): number {
|
||||
// passe diese Reihenfolge ggf. an deine Felder an
|
||||
return (
|
||||
(m as any).rounds ??
|
||||
(m as any).roundCount ??
|
||||
@ -57,7 +56,7 @@ function getRounds(m: Partial<MatchStats>): number {
|
||||
) || 0
|
||||
}
|
||||
|
||||
/* – kleine Sparkline ohne Lib – */
|
||||
/* – kleine Sparkline – */
|
||||
function Sparkline({ values }: { values: number[] }) {
|
||||
const W = 180, H = 42, pad = 4
|
||||
const n = Math.max(1, values.length)
|
||||
@ -116,6 +115,28 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||
)
|
||||
}
|
||||
|
||||
/* Performance-Helper */
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||
const KD_CAP = 2.0
|
||||
const ADR_CAP = 120
|
||||
const KPR_CAP = 1.2
|
||||
const APR_CAP = 0.6
|
||||
function perfOfMatch(m: Partial<MatchStats>) {
|
||||
const k = m.kills ?? 0
|
||||
const d = m.deaths ?? 0
|
||||
const a = m.assists ?? 0
|
||||
const r = Math.max(1, getRounds(m))
|
||||
const kd = d > 0 ? k / d : KD_CAP
|
||||
const adr = (m.totalDamage ?? 0) / r
|
||||
const kpr = k / r
|
||||
const apr = a / r
|
||||
const kdS = clamp01(kd / KD_CAP)
|
||||
const adrS = clamp01(adr / ADR_CAP)
|
||||
const kprS = clamp01(kpr / KPR_CAP)
|
||||
const aprS = clamp01(apr / APR_CAP)
|
||||
return 0.45 * kdS + 0.45 * adrS + 0.10 * (0.7 * kprS + 0.3 * aprS)
|
||||
}
|
||||
|
||||
/* – Hauptkomponente – */
|
||||
export default function StatsView({ steamId, stats }: Props) {
|
||||
const { data: session } = useSession()
|
||||
@ -136,40 +157,25 @@ export default function StatsView({ steamId, stats }: Props) {
|
||||
const totalAssists = matches.reduce((s, m) => s + (m.assists ?? 0), 0)
|
||||
const totalDamage = matches.reduce((s, m) => s + (m.totalDamage ?? 0), 0)
|
||||
|
||||
// ►► ADR-Berechnung
|
||||
// ► ADR-Berechnung
|
||||
const totalRounds = matches.reduce((s, m) => s + getRounds(m), 0)
|
||||
const adrOverall = totalRounds > 0 ? totalDamage / totalRounds : (matches.length ? totalDamage / matches.length : 0)
|
||||
|
||||
const overallKD = kd(totalKills, totalDeaths)
|
||||
const dateLabels = matches.map((m) => fmtShortDate(m.date))
|
||||
|
||||
|
||||
const KD_CAP = 5.0; // maximaler Wert für K/D im Form-Chart
|
||||
|
||||
/* – Form: letzte 12 K/D – */
|
||||
// 1) Zeitlich sortieren (alt → neu)
|
||||
/* – Performance: letzte 12 Spiele – */
|
||||
const sorted = useMemo(
|
||||
() => [...matches].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()),
|
||||
[matches]
|
||||
);
|
||||
|
||||
// 2) Letzte 12
|
||||
const last = sorted.slice(-12);
|
||||
|
||||
// 3) „sichere“ K/D-Serie: Infinity abfangen & deckeln
|
||||
const kdSeries = last.map(m => {
|
||||
const k = m.kills ?? 0;
|
||||
const d = m.deaths ?? 0;
|
||||
const v = d > 0 ? k / d : KD_CAP; // Deaths=0 => cap
|
||||
return Math.min(KD_CAP, Math.max(0, v)); // zusätzlich begrenzen
|
||||
});
|
||||
|
||||
// 4) Delta (letzter Punkt vs. Ø der vorherigen)
|
||||
const lastKD = kdSeries.at(-1) ?? 0;
|
||||
const prevKD = kdSeries.length > 1
|
||||
? kdSeries.slice(0, -1).reduce((a, b) => a + b, 0) / (kdSeries.length - 1)
|
||||
: lastKD;
|
||||
const deltaKD = lastKD - prevKD;
|
||||
)
|
||||
const last = sorted.slice(-12)
|
||||
const perfSeries = last.map(perfOfMatch)
|
||||
const lastPerf = perfSeries.at(-1) ?? 0
|
||||
const prevPerf = perfSeries.length > 1
|
||||
? perfSeries.slice(0, -1).reduce((a, b) => a + b, 0) / (perfSeries.length - 1)
|
||||
: lastPerf
|
||||
const deltaPerf = lastPerf - prevPerf
|
||||
|
||||
/* – Aggregat: Kills je Map – */
|
||||
const killsPerMap = useMemo(() => {
|
||||
@ -217,7 +223,7 @@ export default function StatsView({ steamId, stats }: Props) {
|
||||
const adrPerMatch = matches.map((m) => {
|
||||
const r = getRounds(m)
|
||||
const dmg = m.totalDamage ?? 0
|
||||
return r > 0 ? dmg / r : dmg // Fallback falls Runden fehlen
|
||||
return r > 0 ? dmg / r : dmg
|
||||
})
|
||||
|
||||
return (
|
||||
@ -247,12 +253,12 @@ export default function StatsView({ steamId, stats }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Row: Form + KPIs */}
|
||||
{/* Top Row: Performance + KPIs */}
|
||||
<Card maxWidth="full">
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-white/60">Aktuelle Form</div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-white/60">Performance</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className={['rounded-md px-2 py-1 text-sm font-semibold ring-1 ring-inset', tintForKD(overallKD)].join(
|
||||
@ -264,16 +270,16 @@ export default function StatsView({ steamId, stats }: Props) {
|
||||
<span
|
||||
className={[
|
||||
'rounded-md px-2 py-1 text-xs ring-1 ring-inset',
|
||||
deltaKD >= 0
|
||||
deltaPerf >= 0
|
||||
? 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
|
||||
: 'text-rose-400 bg-rose-500/10 ring-rose-500/20',
|
||||
].join(' ')}
|
||||
>
|
||||
{deltaKD >= 0 ? '▲' : '▼'} {Math.abs(deltaKD).toFixed(2)}
|
||||
{deltaPerf >= 0 ? '▲' : '▼'} {Math.abs(deltaPerf).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline values={kdSeries} />
|
||||
<Sparkline values={perfSeries.length ? perfSeries : [0,0]} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:w-[360px]">
|
||||
@ -292,7 +298,6 @@ export default function StatsView({ steamId, stats }: Props) {
|
||||
value={overallKD === Infinity ? '∞' : overallKD.toFixed(2)}
|
||||
className={['ring-1 ring-inset', tintForKD(overallKD)].join(' ')}
|
||||
/>
|
||||
{/* ▼ NEU: ADR statt Gesamtdamage */}
|
||||
<Metric label="ADR (Ø Damage pro Match)" value={fmtADR(adrOverall)} />
|
||||
</div>
|
||||
|
||||
@ -346,12 +351,12 @@ export default function StatsView({ steamId, stats }: Props) {
|
||||
{
|
||||
label: 'Win %',
|
||||
data: winPct,
|
||||
backgroundColor: 'rgba(16,185,129,.85)', // emerald
|
||||
backgroundColor: 'rgba(16,185,129,.85)',
|
||||
},
|
||||
{
|
||||
label: 'Loss %',
|
||||
data: lossPct,
|
||||
backgroundColor: 'rgba(239,68,68,.85)', // red
|
||||
backgroundColor: 'rgba(239,68,68,.85)',
|
||||
},
|
||||
]}
|
||||
options={{
|
||||
@ -417,7 +422,6 @@ export default function StatsView({ steamId, stats }: Props) {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ▼ NEU: ADR pro Match */}
|
||||
<Section title="ADR pro Match">
|
||||
<Chart
|
||||
type="bar"
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
// /src/app/[locale]/components/settings/account/FaceitLink.tsx
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Button from '../../Button'
|
||||
|
||||
type Props = {
|
||||
isLinked: boolean
|
||||
nickname?: string | null // (unbenutzt, kann entfernt werden)
|
||||
avatar?: string | null // (unbenutzt, kann entfernt werden)
|
||||
}
|
||||
|
||||
export default function FaceitLink({ isLinked }: Props) {
|
||||
const [loading, setLoading] = useState<'connect' | 'disconnect' | null>(null)
|
||||
|
||||
const onConnect = () => {
|
||||
setLoading('connect')
|
||||
window.location.assign('/api/faceit/login')
|
||||
}
|
||||
|
||||
const onDisconnect = async () => {
|
||||
setLoading('disconnect')
|
||||
try {
|
||||
await fetch('/api/faceit/disconnect', { method: 'POST' })
|
||||
window.location.reload()
|
||||
} catch {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-3 sm:py-4">
|
||||
<div className="grid items-start gap-y-1.5 sm:gap-y-0 sm:gap-x-5 sm:grid-cols-12">
|
||||
{/* Label & Beschreibung (links) */}
|
||||
<div className="sm:col-span-4 2xl:col-span-2">
|
||||
<label className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
|
||||
FACEIT
|
||||
</label>
|
||||
<p className="mt-1 text-[13px] leading-5 text-gray-500 dark:text-neutral-500">
|
||||
Verknüpfe deinen FACEIT-Account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nur der Action-Button (rechts) */}
|
||||
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
||||
{isLinked ? (
|
||||
<Button
|
||||
onClick={onDisconnect}
|
||||
disabled={loading !== null}
|
||||
aria-busy={loading === 'disconnect'}
|
||||
className="text-sm font-medium text-red-600 hover:text-red-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading === 'disconnect' ? 'Trennen…' : 'Trennen'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onConnect}
|
||||
color='blue'
|
||||
//disabled={loading !== null}
|
||||
disabled={true}
|
||||
aria-busy={loading === 'connect'}
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading === 'connect' ? 'Verbinden…' : 'Mit FACEIT verbinden'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -62,7 +62,6 @@ export default async function RootLayout({children, params}: Props) {
|
||||
|
||||
{/* Rechte Spalte füllt Höhe; wichtig: min-h-0, damit child scrollen darf */}
|
||||
<div className="min-w-0 flex flex-col h-dvh min-h-0">
|
||||
{/* Nur HIER scrollen */}
|
||||
<main className="flex-1 min-w-0 min-h-0 overflow-auto">
|
||||
<div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div>
|
||||
</main>
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
// /src/app/profile/[steamId]/ProfileHeader.tsx
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { Tabs } from '../../components/Tabs'
|
||||
import Pill from '../../components/Pill'
|
||||
import PremierRankBadge from '../../components/PremierRankBadge'
|
||||
import FaceitStat from '../../components/FaceitStat'
|
||||
import Image from 'next/image'
|
||||
|
||||
type Props = {
|
||||
user: {
|
||||
@ -18,35 +22,13 @@ type Props = {
|
||||
communityBanned: boolean | null
|
||||
economyBan: string | null
|
||||
lastBanCheck: Date | null
|
||||
faceitNickname?: string | null
|
||||
faceitUrl?: string | null
|
||||
faceitLevel?: number | null
|
||||
faceitElo?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(d?: Date | null) {
|
||||
if (!d) return 'unbekannt'
|
||||
const sec = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000))
|
||||
const units: [number, string][] = [
|
||||
[60, 'Sek.'], [60, 'Min.'], [24, 'Std.'], [7, 'Tg.'], [4.35, 'Wo.'],
|
||||
[12, 'Mon.'], [Number.POSITIVE_INFINITY, 'J.'],
|
||||
]
|
||||
let value = sec
|
||||
let label = 'Sek.'
|
||||
for (const [k, l] of units) {
|
||||
if (value < k) { label = l; break }
|
||||
value = Math.floor(value / k)
|
||||
label = l
|
||||
}
|
||||
return `${value} ${label}`
|
||||
}
|
||||
|
||||
function StatusDot({ s }: { s: Props['user']['status'] }) {
|
||||
const map: Record<string, string> = {
|
||||
online: 'bg-emerald-500',
|
||||
away: 'bg-amber-400',
|
||||
offline: 'bg-neutral-500',
|
||||
}
|
||||
return <span className={`inline-block size-2.5 rounded-full ${map[s]}`} />
|
||||
}
|
||||
|
||||
export default function ProfileHeader({ user: u }: Props) {
|
||||
const showVac = !!u.vacBanned || (u.numberOfVACBans ?? 0) > 0
|
||||
const showGameBan = (u.numberOfGameBans ?? 0) > 0
|
||||
@ -54,117 +36,90 @@ export default function ProfileHeader({ user: u }: Props) {
|
||||
const showEcon = !!u.economyBan && u.economyBan !== 'none'
|
||||
const showLastBan = typeof u.daysSinceLastBan === 'number'
|
||||
const hasAnyBan = showVac || showGameBan || showComm || showEcon
|
||||
const hasFaceit = !!u.faceitUrl
|
||||
|
||||
return (
|
||||
<header className="flex flex-col gap-5">
|
||||
{/* Top */}
|
||||
<div className="flex items-start gap-5">
|
||||
<img
|
||||
<div className="relative flex items-start gap-5">
|
||||
{/* Actions oben rechts */}
|
||||
<div className="absolute right-0 top-0 flex items-center gap-1.5">
|
||||
<Link
|
||||
href={`https://steamcommunity.com/profiles/${u.steamId}`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label="Steam-Profil öffnen" title={`Steam-Profil von ${u.name ?? ''}`}
|
||||
className="inline-flex size-9 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
|
||||
>
|
||||
<i className="fab fa-steam text-[18px]" aria-hidden />
|
||||
</Link>
|
||||
{hasFaceit && (
|
||||
<Link
|
||||
href={u.faceitUrl!.replace('{lang}', 'en')}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label="Faceit-Profil öffnen" title={`Faceit-Profil von ${u.faceitNickname ?? u.name ?? ''}`}
|
||||
className="inline-flex size-9 items-center justify-center rounded-md text-white/80 bg-white/5 ring-1 ring-white/10 hover:bg-white/10"
|
||||
>
|
||||
<img src="/assets/img/logos/faceit.svg" alt="" className="h-5 w-5" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<Image
|
||||
src={u.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
||||
alt={u.name ?? ''}
|
||||
width={88}
|
||||
height={88}
|
||||
className="rounded-full border border-neutral-700 bg-neutral-900 object-cover"
|
||||
/>
|
||||
|
||||
{/* Textblock */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold">
|
||||
<div className="flex items-center gap-3 flex-wrap pr-24">
|
||||
{/* pr-24, damit die absolut positionierten Actions rechts nicht überlappen */}
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold truncate">
|
||||
{u.name ?? 'Unbekannt'}
|
||||
</h1>
|
||||
{/* PremierRankBadge optional */}
|
||||
<PremierRankBadge rank={u.premierRank ? u.premierRank : 0} />
|
||||
{hasFaceit && (
|
||||
<FaceitStat level={u.faceitLevel ?? undefined} elo={u.faceitElo ?? undefined} size="lg" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta-Zeile */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-sm text-neutral-400">
|
||||
<div className="rounded bg-neutral-800/70 px-2 py-0.5 text-neutral-200">{u.steamId}</div>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<StatusDot s={u.status} />
|
||||
<span className="capitalize">{u.status}</span>
|
||||
{u.status !== 'online' && u.lastActiveAt && (
|
||||
<span className="opacity-70">({timeAgo(u.lastActiveAt)} ago)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<Link
|
||||
href={`https://steamcommunity.com/profiles/${u.steamId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Steam-Profil öffnen"
|
||||
title="Steam-Profil"
|
||||
className="inline-flex items-center justify-center rounded-md p-1.5
|
||||
text-white/70 hover:text-white hover:bg-white/10 transition"
|
||||
>
|
||||
<i className="fab fa-steam text-lg" aria-hidden />
|
||||
<span className="sr-only">Steam-Profil</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Kompakte Pills wie in MatchesList */}
|
||||
{/* Pills */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{hasAnyBan && showVac && (
|
||||
<Pill
|
||||
tone="danger"
|
||||
title="VAC-Bans auf diesem Account"
|
||||
className="ml-1"
|
||||
>
|
||||
{(!!u.vacBanned || (u.numberOfVACBans ?? 0) > 0) && (
|
||||
<Pill tone="danger" title="VAC-Bans">
|
||||
<span>VAC</span>
|
||||
{typeof u.numberOfVACBans === 'number' && (
|
||||
<span className="tabular-nums">×{u.numberOfVACBans}</span>
|
||||
)}
|
||||
</Pill>
|
||||
)}
|
||||
|
||||
{hasAnyBan && showGameBan && (
|
||||
<Pill
|
||||
tone="danger"
|
||||
title="Game Bans (Spielbanns)"
|
||||
className="ml-1"
|
||||
>
|
||||
{(u.numberOfGameBans ?? 0) > 0 && (
|
||||
<Pill tone="danger" title="Game Bans">
|
||||
<span>BAN</span>
|
||||
<span className="tabular-nums">×{u.numberOfGameBans}</span>
|
||||
</Pill>
|
||||
)}
|
||||
|
||||
{hasAnyBan && showComm && (
|
||||
<Pill
|
||||
tone="danger"
|
||||
title="Community Ban aktiv"
|
||||
className="ml-1"
|
||||
>
|
||||
<span>COMM</span>
|
||||
<span>BAN</span>
|
||||
{!!u.communityBanned && (
|
||||
<Pill tone="danger" title="Community Ban">
|
||||
<span>COMM</span><span>BAN</span>
|
||||
</Pill>
|
||||
)}
|
||||
|
||||
{hasAnyBan && showEcon && (
|
||||
<Pill
|
||||
tone="warn"
|
||||
title={`Economy Status: ${u.economyBan ?? ''}`}
|
||||
className="ml-1"
|
||||
>
|
||||
<span>ECON</span>
|
||||
<span className="uppercase">{u.economyBan}</span>
|
||||
{!!u.economyBan && u.economyBan !== 'none' && (
|
||||
<Pill tone="warn" title={`Economy: ${u.economyBan}`}>
|
||||
<span>ECON</span><span className="uppercase">{u.economyBan}</span>
|
||||
</Pill>
|
||||
)}
|
||||
|
||||
{hasAnyBan && showLastBan && (
|
||||
<Pill
|
||||
tone="neutral"
|
||||
title="Tage seit letztem Ban"
|
||||
className="ml-1"
|
||||
>
|
||||
{hasAnyBan && typeof u.daysSinceLastBan === 'number' && (
|
||||
<Pill tone="neutral" title="Tage seit letztem Ban">
|
||||
<span>Letzter Ban</span>
|
||||
<span className="tabular-nums">{u.daysSinceLastBan} Tg.</span>
|
||||
</Pill>
|
||||
)}
|
||||
|
||||
{u.lastBanCheck && (
|
||||
<Pill
|
||||
tone="neutral"
|
||||
title="Zeitpunkt der letzten Prüfung"
|
||||
className="ml-1"
|
||||
>
|
||||
<Pill tone="neutral" title="Zeitpunkt der letzten Prüfung">
|
||||
<span>geprüft</span>
|
||||
<span>{u.lastBanCheck.toLocaleDateString()}</span>
|
||||
</Pill>
|
||||
@ -181,4 +136,4 @@ export default function ProfileHeader({ user: u }: Props) {
|
||||
</Tabs>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,12 @@ export default async function ProfileLayout({
|
||||
status: true, lastActiveAt: true,
|
||||
vacBanned: true, numberOfVACBans: true, numberOfGameBans: true,
|
||||
daysSinceLastBan: true, communityBanned: true, economyBan: true, lastBanCheck: true,
|
||||
faceitUrl: true, faceitNickname: true,
|
||||
faceitGames: {
|
||||
where: { game: 'cs2' },
|
||||
select: { skillLevel: true, elo: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!user) return notFound()
|
||||
@ -29,7 +35,7 @@ export default async function ProfileLayout({
|
||||
<Card
|
||||
maxWidth="auto"
|
||||
height="100%"
|
||||
bodyScrollable={false} // ⬅️ Card selbst scrollt NICHT
|
||||
bodyScrollable={false}
|
||||
className="h-full"
|
||||
>
|
||||
{/* Inneres Layout: Header fix, nur Content scrollt */}
|
||||
@ -37,12 +43,20 @@ export default async function ProfileLayout({
|
||||
{/* fester Header */}
|
||||
<div className="shrink-0 border-b border-white/10 dark:border-white/10">
|
||||
<div className="py-4">
|
||||
<ProfileHeader user={user} />
|
||||
<ProfileHeader
|
||||
user={{
|
||||
...user,
|
||||
faceitNickname: user.faceitNickname,
|
||||
faceitUrl: user.faceitUrl,
|
||||
faceitLevel: user.faceitGames[0]?.skillLevel ?? null,
|
||||
faceitElo: user.faceitGames[0]?.elo ?? null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* scrollender Content */}
|
||||
<div className="flex-1 min-h-0 py-6 px-3 space-y-6">
|
||||
<div className="flex-1 min-h-0 overflow-auto overscroll-contain py-6 px-3 space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,8 +5,6 @@ import { getServerSession } from 'next-auth'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import AuthCodeSettings from '../../components/settings/account/AuthCodeSettings'
|
||||
import LatestKnownCodeSettings from '../../components/settings/account/ShareCodeSettings'
|
||||
import FaceitLink from '../../components/settings/account/FaceitLink'
|
||||
// import { authOptions } from '@/server/auth' // falls du AuthOptions getrennt hast
|
||||
|
||||
export default async function AccountSection() {
|
||||
const tSettings = await getTranslations('settings')
|
||||
@ -31,11 +29,6 @@ export default async function AccountSection() {
|
||||
<form className="border-t border-gray-200 dark:border-neutral-700">
|
||||
<AuthCodeSettings />
|
||||
<LatestKnownCodeSettings />
|
||||
<FaceitLink
|
||||
isLinked={!!user?.faceitId}
|
||||
nickname={user?.faceitNickname ?? undefined}
|
||||
avatar={user?.faceitAvatar ?? undefined}
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
|
||||
@ -22,14 +22,22 @@ export async function GET(req: NextRequest) {
|
||||
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' } })
|
||||
// 1) Config aus DB – nur benötigte Felder
|
||||
const cfg = await prisma.serverConfig.findUnique({
|
||||
where: { id: 'default' },
|
||||
select: {
|
||||
serverIp: true,
|
||||
serverPassword: true, // nur für connectHref (wird nicht zurückgegeben)
|
||||
pterodactylServerId: true, // wird IMMER zurückgegeben
|
||||
},
|
||||
})
|
||||
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
|
||||
// 2) Optional: Teilnehmer-Gate fürs Match (bleibt bestehen)
|
||||
if (matchId) {
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
@ -55,6 +63,10 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Response bauen (serverId immer inkludieren)
|
||||
const connectHref = buildConnectHref(cfg.serverIp, cfg.serverPassword ?? undefined, port)
|
||||
return NextResponse.json({ connectHref }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
return NextResponse.json(
|
||||
{ connectHref, serverId: cfg.pterodactylServerId ?? null },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
// /src/app/api/user/[steamId]/winrate/route.ts
|
||||
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||
|
||||
/** Map-Key normalisieren (z.B. "maps/de_inferno.bsp" -> "de_inferno") */
|
||||
function normMapKey(raw?: string | null) {
|
||||
return (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
||||
}
|
||||
|
||||
// Label-/Order-Lookups aus MAP_OPTIONS
|
||||
const MAP_LABEL_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.label] as const))
|
||||
const MAP_ACTIVE_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.active] as const))
|
||||
const MAP_ORDER_BY_KEY = new Map(MAP_OPTIONS.map((o, idx) => [o.key, idx] as const))
|
||||
|
||||
// Pseudo-Maps ignorieren
|
||||
const IGNORED_KEYS = new Set(['lobby_mapvote'])
|
||||
|
||||
function labelFor(key: string) {
|
||||
return (
|
||||
MAP_LABEL_BY_KEY.get(key) ??
|
||||
key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
)
|
||||
}
|
||||
|
||||
/** Gewinner-Seite ermitteln; wenn scoreA/scoreB gleich => Tie */
|
||||
function computeOutcome(m: {
|
||||
winnerTeam: string | null
|
||||
teamAId: string | null
|
||||
teamBId: string | null
|
||||
scoreA: number | null
|
||||
scoreB: number | null
|
||||
}): 'A' | 'B' | 'TIE' | null {
|
||||
// 1) Score bevorzugen, da eindeutig (und Ties erkennbar)
|
||||
if (typeof m.scoreA === 'number' && typeof m.scoreB === 'number') {
|
||||
if (m.scoreA > m.scoreB) return 'A'
|
||||
if (m.scoreB > m.scoreA) return 'B'
|
||||
return 'TIE'
|
||||
}
|
||||
|
||||
// 2) Fallback: winnerTeam kann 'A'/'B' oder teamAId/teamBId sein
|
||||
const w = (m.winnerTeam ?? '').trim().toLowerCase()
|
||||
if (w) {
|
||||
if (w === 'a' || w === (m.teamAId ?? '').toLowerCase()) return 'A'
|
||||
if (w === 'b' || w === (m.teamBId ?? '').toLowerCase()) return 'B'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/user/:steamId/winrate?types=premier,competitive&onlyActive=true
|
||||
*
|
||||
* Antwort:
|
||||
* {
|
||||
* labels: string[]
|
||||
* keys: string[]
|
||||
* values: number[] // Winrate 0..100 (1 Nachkomma), (W + 0.5*T) / (W+L+T)
|
||||
* byMap: Record<key, { wins, losses, ties, total, pct }>
|
||||
* }
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: { steamId: string } }) {
|
||||
const steamId = params.steamId
|
||||
if (!steamId) return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const typesParam = searchParams.get('types')
|
||||
const types = typesParam ? typesParam.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
const onlyActive = (searchParams.get('onlyActive') ?? 'true').toLowerCase() !== 'false'
|
||||
|
||||
// Relevante Matches holen; inkl. MatchPlayer-Team-Zuordnung als Fallback
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
players: { some: { steamId } },
|
||||
...(types.length ? { matchType: { in: types } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
map: true,
|
||||
scoreA: true,
|
||||
scoreB: true,
|
||||
teamAId: true,
|
||||
teamBId: true,
|
||||
winnerTeam: true,
|
||||
teamAUsers: { select: { steamId: true } },
|
||||
teamBUsers: { select: { steamId: true } },
|
||||
players: {
|
||||
where: { steamId },
|
||||
select: { teamId: true }, // 👈 Fallback für Team-Zuordnung
|
||||
},
|
||||
},
|
||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
||||
take: 1000,
|
||||
})
|
||||
|
||||
type Agg = { wins: number; losses: number; ties: number; total: number; pct: number }
|
||||
const byMap: Record<string, Agg> = {}
|
||||
|
||||
for (const m of matches) {
|
||||
const keyRaw = normMapKey(m.map) || 'unknown'
|
||||
if (IGNORED_KEYS.has(keyRaw)) continue
|
||||
if (onlyActive && MAP_ACTIVE_BY_KEY.has(keyRaw) && !MAP_ACTIVE_BY_KEY.get(keyRaw)) continue
|
||||
const key = keyRaw
|
||||
if (!byMap[key]) byMap[key] = { wins: 0, losses: 0, ties: 0, total: 0, pct: 0 }
|
||||
|
||||
// ◀ Team-Zuordnung robust bestimmen
|
||||
const inA_fromRel = m.teamAUsers.some(u => u.steamId === steamId)
|
||||
const inB_fromRel = m.teamBUsers.some(u => u.steamId === steamId)
|
||||
|
||||
let side: 'A' | 'B' | null = null
|
||||
if (inA_fromRel) side = 'A'
|
||||
else if (inB_fromRel) side = 'B'
|
||||
else {
|
||||
// Fallback via MatchPlayer.teamId
|
||||
const teamId = m.players[0]?.teamId ?? null
|
||||
if (teamId && m.teamAId && teamId === m.teamAId) side = 'A'
|
||||
else if (teamId && m.teamBId && teamId === m.teamBId) side = 'B'
|
||||
}
|
||||
if (!side) continue // keine Teamzuordnung ⇒ ignorieren
|
||||
|
||||
const outcome = computeOutcome({
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
teamAId: m.teamAId ?? null,
|
||||
teamBId: m.teamBId ?? null,
|
||||
scoreA: m.scoreA ?? null,
|
||||
scoreB: m.scoreB ?? null,
|
||||
})
|
||||
|
||||
// Nur Matches mit ermittelbarem Ergebnis zählen
|
||||
if (!outcome) continue
|
||||
|
||||
if (outcome === 'TIE') {
|
||||
byMap[key].ties += 1
|
||||
byMap[key].total += 1
|
||||
} else if (outcome === side) {
|
||||
byMap[key].wins += 1
|
||||
byMap[key].total += 1
|
||||
} else {
|
||||
byMap[key].losses += 1
|
||||
byMap[key].total += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Prozente berechnen: (W + 0.5*T) / (W+L+T)
|
||||
const presentKeys = Object.keys(byMap)
|
||||
for (const k of presentKeys) {
|
||||
const it = byMap[k]
|
||||
const denom = it.wins + it.losses + it.ties
|
||||
const ratio = denom > 0 ? (it.wins + 0.5 * it.ties) / denom : 0
|
||||
it.pct = Math.round(ratio * 1000) // Keine Nachkommastelle
|
||||
}
|
||||
|
||||
// Sortierung: erst MAP_OPTIONS-Reihenfolge, dann Label
|
||||
const sortedKeys = presentKeys.sort((a, b) => {
|
||||
const ia = MAP_ORDER_BY_KEY.has(a) ? (MAP_ORDER_BY_KEY.get(a) as number) : Number.POSITIVE_INFINITY
|
||||
const ib = MAP_ORDER_BY_KEY.has(b) ? (MAP_ORDER_BY_KEY.get(b) as number) : Number.POSITIVE_INFINITY
|
||||
if (ia !== ib) return ia - ib
|
||||
return labelFor(a).localeCompare(labelFor(b), 'de', { sensitivity: 'base' })
|
||||
})
|
||||
|
||||
const labels = sortedKeys.map(k => labelFor(k))
|
||||
const values = sortedKeys.map(k => byMap[k].pct)
|
||||
|
||||
return NextResponse.json(
|
||||
{ labels, keys: sortedKeys, values, byMap },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[winrate] Fehler:', err)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
206
src/app/api/user/winrate/route.ts
Normal file
@ -0,0 +1,206 @@
|
||||
// /src/app/api/user/winrate/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||
|
||||
// ===== Helpers wie in der Einzelroute =====
|
||||
function normMapKey(raw?: string | null) {
|
||||
return (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
||||
}
|
||||
const MAP_LABEL_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.label] as const))
|
||||
const MAP_ACTIVE_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.active] as const))
|
||||
const MAP_ORDER_BY_KEY = new Map(MAP_OPTIONS.map((o, idx) => [o.key, idx] as const))
|
||||
const IGNORED_KEYS = new Set(['lobby_mapvote'])
|
||||
|
||||
function labelFor(key: string) {
|
||||
return (
|
||||
MAP_LABEL_BY_KEY.get(key) ??
|
||||
key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
)
|
||||
}
|
||||
|
||||
function computeOutcome(m: {
|
||||
winnerTeam: string | null
|
||||
teamAId: string | null
|
||||
teamBId: string | null
|
||||
scoreA: number | null
|
||||
scoreB: number | null
|
||||
}): 'A' | 'B' | 'TIE' | null {
|
||||
if (typeof m.scoreA === 'number' && typeof m.scoreB === 'number') {
|
||||
if (m.scoreA > m.scoreB) return 'A'
|
||||
if (m.scoreB > m.scoreA) return 'B'
|
||||
return 'TIE'
|
||||
}
|
||||
const w = (m.winnerTeam ?? '').trim().toLowerCase()
|
||||
if (w) {
|
||||
if (w === 'a' || w === (m.teamAId ?? '').toLowerCase()) return 'A'
|
||||
if (w === 'b' || w === (m.teamBId ?? '').toLowerCase()) return 'B'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ===== GET (Batch) =====
|
||||
//
|
||||
// /api/user/winrate?steamIds=ID1,ID2,ID3&types=premier,competitive&onlyActive=true
|
||||
//
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
|
||||
// steamIds via Query (oder Body bei POST, siehe unten)
|
||||
const idsParam = searchParams.get('steamIds')?.trim() ?? ''
|
||||
const steamIds = idsParam
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!steamIds.length) {
|
||||
return NextResponse.json({ error: 'steamIds fehlt' }, { status: 400 })
|
||||
}
|
||||
|
||||
const typesCsv = searchParams.get('types')?.trim();
|
||||
const typesRepeated = searchParams.getAll('types'); // alle wiederholten
|
||||
const types = (typesRepeated.length ? typesRepeated : (typesCsv ? typesCsv.split(',') : []))
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
const onlyActive = (searchParams.get('onlyActive') ?? 'true').toLowerCase() !== 'false'
|
||||
|
||||
// Alle relevanten Matches, in denen mind. einer der Spieler dabei ist
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
players: { some: { steamId: { in: steamIds } } },
|
||||
...(types.length ? { matchType: { in: types } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
map: true,
|
||||
scoreA: true,
|
||||
scoreB: true,
|
||||
teamAId: true,
|
||||
teamBId: true,
|
||||
winnerTeam: true,
|
||||
// Für robuste Team-Zuordnung:
|
||||
teamAUsers: { select: { steamId: true } },
|
||||
teamBUsers: { select: { steamId: true } },
|
||||
// Nur die Spieler aus unserer Batch (mit teamId-Fallback)
|
||||
players: {
|
||||
where: { steamId: { in: steamIds } },
|
||||
select: { steamId: true, teamId: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
||||
take: 3000, // Hard cap (Performance)
|
||||
})
|
||||
|
||||
type Agg = { wins: number; losses: number; ties: number; total: number; pct: number }
|
||||
// pro Spieler -> pro Map aggregieren
|
||||
const aggByPlayer: Record<string, Record<string, Agg>> = Object.fromEntries(
|
||||
steamIds.map(id => [id, {} as Record<string, Agg>])
|
||||
)
|
||||
|
||||
for (const m of matches) {
|
||||
const keyRaw = normMapKey(m.map) || 'unknown'
|
||||
if (IGNORED_KEYS.has(keyRaw)) continue
|
||||
if (onlyActive && MAP_ACTIVE_BY_KEY.has(keyRaw) && !MAP_ACTIVE_BY_KEY.get(keyRaw)) continue
|
||||
const outcome = computeOutcome({
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
teamAId: m.teamAId ?? null,
|
||||
teamBId: m.teamBId ?? null,
|
||||
scoreA: m.scoreA ?? null,
|
||||
scoreB: m.scoreB ?? null,
|
||||
})
|
||||
if (!outcome) continue
|
||||
|
||||
// Nur für die in diesem Match tatsächlich vorkommenden Batch-Spieler
|
||||
for (const p of m.players) {
|
||||
const steamId = p.steamId
|
||||
const store = (aggByPlayer[steamId] ||= {})
|
||||
const key = keyRaw
|
||||
if (!store[key]) store[key] = { wins: 0, losses: 0, ties: 0, total: 0, pct: 0 }
|
||||
|
||||
// Team-Zuordnung robust:
|
||||
const inA_fromRel = m.teamAUsers.some(u => u.steamId === steamId)
|
||||
const inB_fromRel = m.teamBUsers.some(u => u.steamId === steamId)
|
||||
|
||||
let side: 'A' | 'B' | null = null
|
||||
if (inA_fromRel) side = 'A'
|
||||
else if (inB_fromRel) side = 'B'
|
||||
else {
|
||||
const teamId = p.teamId ?? null
|
||||
if (teamId && m.teamAId && teamId === m.teamAId) side = 'A'
|
||||
else if (teamId && m.teamBId && teamId === m.teamBId) side = 'B'
|
||||
}
|
||||
if (!side) continue
|
||||
|
||||
if (outcome === 'TIE') {
|
||||
store[key].ties += 1
|
||||
store[key].total += 1
|
||||
} else if (outcome === side) {
|
||||
store[key].wins += 1
|
||||
store[key].total += 1
|
||||
} else {
|
||||
store[key].losses += 1
|
||||
store[key].total += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prozente & sortierte Arrays je Spieler bauen
|
||||
const byPlayer: Record<
|
||||
string,
|
||||
{ labels: string[]; keys: string[]; values: number[]; byMap: Record<string, Agg> }
|
||||
> = {}
|
||||
|
||||
for (const steamId of steamIds) {
|
||||
const byMap = aggByPlayer[steamId] ?? {}
|
||||
// (W + 0.5*T) / (W+L+T)
|
||||
for (const k of Object.keys(byMap)) {
|
||||
const it = byMap[k]
|
||||
const denom = it.wins + it.losses + it.ties
|
||||
const ratio = denom > 0 ? (it.wins + 0.5 * it.ties) / denom : 0
|
||||
it.pct = Math.round(ratio * 1000) // 1 Nachkomma (×10) wie bisher
|
||||
}
|
||||
|
||||
const presentKeys = Object.keys(byMap)
|
||||
const sortedKeys = presentKeys.sort((a, b) => {
|
||||
const ia = MAP_ORDER_BY_KEY.has(a) ? (MAP_ORDER_BY_KEY.get(a) as number) : Number.POSITIVE_INFINITY
|
||||
const ib = MAP_ORDER_BY_KEY.has(b) ? (MAP_ORDER_BY_KEY.get(b) as number) : Number.POSITIVE_INFINITY
|
||||
if (ia !== ib) return ia - ib
|
||||
return labelFor(a).localeCompare(labelFor(b), 'de', { sensitivity: 'base' })
|
||||
})
|
||||
|
||||
byPlayer[steamId] = {
|
||||
labels: sortedKeys.map(k => labelFor(k)),
|
||||
keys: sortedKeys,
|
||||
values: sortedKeys.map(k => byMap[k].pct),
|
||||
byMap,
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ steamIds, byPlayer },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[winrate-batch] Fehler:', err)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: POST mit JSON { steamIds: string[], types?: string[], onlyActive?: boolean }
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => null)
|
||||
if (!body || !Array.isArray(body.steamIds) || !body.steamIds.length) {
|
||||
return NextResponse.json({ error: 'steamIds fehlt/leer' }, { status: 400 })
|
||||
}
|
||||
const params = new URL(req.url)
|
||||
// baue Query-URL und reuse GET-Handler
|
||||
params.searchParams.set('steamIds', body.steamIds.join(','))
|
||||
if (Array.isArray(body.types) && body.types.length) {
|
||||
params.searchParams.set('types', body.types.join(','))
|
||||
}
|
||||
if (typeof body.onlyActive === 'boolean') {
|
||||
params.searchParams.set('onlyActive', String(body.onlyActive))
|
||||
}
|
||||
return GET(new Request(params.toString()) as any)
|
||||
}
|
||||
@ -147,10 +147,25 @@ exports.Prisma.UserScalarFieldEnum = {
|
||||
faceitId: 'faceitId',
|
||||
faceitNickname: 'faceitNickname',
|
||||
faceitAvatar: 'faceitAvatar',
|
||||
faceitLinkedAt: 'faceitLinkedAt',
|
||||
faceitAccessToken: 'faceitAccessToken',
|
||||
faceitRefreshToken: 'faceitRefreshToken',
|
||||
faceitTokenExpiresAt: 'faceitTokenExpiresAt'
|
||||
faceitCountry: 'faceitCountry',
|
||||
faceitUrl: 'faceitUrl',
|
||||
faceitVerified: 'faceitVerified',
|
||||
faceitActivatedAt: 'faceitActivatedAt',
|
||||
faceitSteamId64: 'faceitSteamId64'
|
||||
};
|
||||
|
||||
exports.Prisma.FaceitGameStatScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userSteamId: 'userSteamId',
|
||||
game: 'game',
|
||||
region: 'region',
|
||||
gamePlayerId: 'gamePlayerId',
|
||||
gamePlayerName: 'gamePlayerName',
|
||||
skillLevel: 'skillLevel',
|
||||
elo: 'elo',
|
||||
skillLabel: 'skillLabel',
|
||||
gameProfileId: 'gameProfileId',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.TeamScalarFieldEnum = {
|
||||
@ -370,6 +385,11 @@ exports.UserStatus = exports.$Enums.UserStatus = {
|
||||
offline: 'offline'
|
||||
};
|
||||
|
||||
exports.FaceitGameId = exports.$Enums.FaceitGameId = {
|
||||
csgo: 'csgo',
|
||||
cs2: 'cs2'
|
||||
};
|
||||
|
||||
exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
|
||||
PENDING: 'PENDING',
|
||||
CONFIRMED: 'CONFIRMED',
|
||||
@ -386,6 +406,7 @@ exports.MapVoteAction = exports.$Enums.MapVoteAction = {
|
||||
|
||||
exports.Prisma.ModelName = {
|
||||
User: 'User',
|
||||
FaceitGameStat: 'FaceitGameStat',
|
||||
Team: 'Team',
|
||||
TeamInvite: 'TeamInvite',
|
||||
Notification: 'Notification',
|
||||
|
||||
2903
src/generated/prisma/index.d.ts
vendored
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-b999d125698de2af5f56011583f17eaff97910cd6225ae9d3f1438a1592b5997",
|
||||
"name": "prisma-client-95b977b21ef98eb4d9fe600b3adfad15a11574a50aec075d54017df21315a04c",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "default.js",
|
||||
|
||||
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp30988
Normal file
@ -68,15 +68,16 @@ model User {
|
||||
lastBanCheck DateTime?
|
||||
|
||||
// FaceIt Account
|
||||
faceitId String? @unique
|
||||
faceitNickname String?
|
||||
faceitAvatar String?
|
||||
faceitLinkedAt DateTime?
|
||||
faceitId String? @unique
|
||||
faceitNickname String?
|
||||
faceitAvatar String?
|
||||
faceitCountry String?
|
||||
faceitUrl String?
|
||||
faceitVerified Boolean? @default(false)
|
||||
faceitActivatedAt DateTime?
|
||||
|
||||
// Falls du Tokens speichern willst (siehe Security-Hinweise unten)
|
||||
faceitAccessToken String?
|
||||
faceitRefreshToken String?
|
||||
faceitTokenExpiresAt DateTime?
|
||||
faceitSteamId64 String?
|
||||
faceitGames FaceitGameStat[]
|
||||
|
||||
@@index([vacBanned])
|
||||
@@index([numberOfVACBans])
|
||||
@ -89,6 +90,29 @@ enum UserStatus {
|
||||
offline
|
||||
}
|
||||
|
||||
enum FaceitGameId {
|
||||
csgo
|
||||
cs2
|
||||
}
|
||||
|
||||
model FaceitGameStat {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [userSteamId], references: [steamId], onDelete: Cascade)
|
||||
userSteamId String
|
||||
game FaceitGameId
|
||||
region String? // "EU"
|
||||
gamePlayerId String? // "76561198000414190"
|
||||
gamePlayerName String? // "Army"
|
||||
skillLevel Int? // 4
|
||||
elo Int? // 1045
|
||||
skillLabel String?
|
||||
gameProfileId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userSteamId, game])
|
||||
@@index([game, elo])
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
|
||||
@ -17,21 +17,14 @@ export default function SSEHandler() {
|
||||
const { setTeam, team } = useTeamStore()
|
||||
const { connect, disconnect, lastEvent, source } = useSSEStore()
|
||||
|
||||
// nur verbinden, wenn eingeloggt & steamId vorhanden
|
||||
const prevSteamId = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (status !== 'authenticated' || !steamId) {
|
||||
// getrennt halten, wenn ausgeloggt / keine ID
|
||||
if (source) disconnect()
|
||||
prevSteamId.current = null
|
||||
return
|
||||
}
|
||||
if (prevSteamId.current !== steamId) {
|
||||
connect(steamId) // useSSEStore verhindert Doppel-Connect
|
||||
prevSteamId.current = steamId
|
||||
}
|
||||
// bewusst KEIN disconnect() im Cleanup, damit global verbunden bleibt
|
||||
}, [status, steamId, connect, disconnect, source])
|
||||
const id = (status === 'authenticated' && steamId) ? steamId : 'guest'; // <- neu
|
||||
if (!source || prevSteamId.current !== id) {
|
||||
connect(id);
|
||||
prevSteamId.current = id;
|
||||
}
|
||||
}, [status, steamId, connect, source]);
|
||||
|
||||
// parallele Reloads pro Team vermeiden
|
||||
const reloadInFlight = useRef<Set<string>>(new Set())
|
||||
|
||||
@ -4,18 +4,8 @@ import { NextRequest } from 'next/server'
|
||||
import Steam from 'next-auth-steam'
|
||||
import { prisma } from './prisma'
|
||||
import type { SteamProfile } from '@/types/steam'
|
||||
import { syncFaceitProfile } from './faceit'
|
||||
|
||||
function isValidIanaTz(tz: unknown): tz is string {
|
||||
if (typeof tz !== 'string' || !tz) return false
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sehr kleiner Fallback-Guesser aus Ländercode -> TZ (optional)
|
||||
function guessTzFromCountry(cc?: string | null): string | null {
|
||||
if (!cc) return null
|
||||
const C = cc.toUpperCase()
|
||||
@ -34,9 +24,7 @@ function guessTzFromCountry(cc?: string | null): string | null {
|
||||
export const authOptions = (req: NextRequest): NextAuthOptions => ({
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
providers: [
|
||||
Steam(req, {
|
||||
clientSecret: process.env.STEAM_API_KEY!,
|
||||
}),
|
||||
Steam(req, { clientSecret: process.env.STEAM_API_KEY! }),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
@ -44,13 +32,12 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
|
||||
const steamProfile = profile as SteamProfile
|
||||
const location = steamProfile.loccountrycode ?? null
|
||||
|
||||
// Gibt es den User schon?
|
||||
// create/update User (wie gehabt)
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { steamId: steamProfile.steamid },
|
||||
select: { timeZone: true },
|
||||
})
|
||||
|
||||
// Falls neu: anlegen inkl. geschätzter TZ
|
||||
if (!existing) {
|
||||
const guessedTz = guessTzFromCountry(location)
|
||||
await prisma.user.create({
|
||||
@ -60,12 +47,10 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
|
||||
avatar: steamProfile.avatarfull,
|
||||
location: location ?? undefined,
|
||||
isAdmin: false,
|
||||
timeZone: guessedTz, // kann null sein
|
||||
timeZone: guessedTz,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Beim Login Stammdaten aktualisieren,
|
||||
// und NUR falls timeZone noch NULL ist, ggf. schätzen.
|
||||
const guessedTz = existing.timeZone ?? guessTzFromCountry(location)
|
||||
await prisma.user.update({
|
||||
where: { steamId: steamProfile.steamid },
|
||||
@ -73,9 +58,7 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
|
||||
name: steamProfile.personaname,
|
||||
avatar: steamProfile.avatarfull,
|
||||
...(location && { location }),
|
||||
...(existing.timeZone == null && guessedTz
|
||||
? { timeZone: guessedTz }
|
||||
: {}),
|
||||
...(existing.timeZone == null && guessedTz ? { timeZone: guessedTz } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -85,7 +68,7 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
|
||||
token.image = steamProfile.avatarfull
|
||||
}
|
||||
|
||||
// DB laden (inkl. timeZone), Team & Admin setzen
|
||||
// DB laden & Flags
|
||||
const userInDb = await prisma.user.findUnique({
|
||||
where: { steamId: token.steamId || token.sub || '' },
|
||||
select: { teamId: true, isAdmin: true, steamId: true, timeZone: true },
|
||||
@ -93,23 +76,22 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
|
||||
|
||||
if (userInDb) {
|
||||
token.team = userInDb.teamId ?? null
|
||||
token.isAdmin =
|
||||
userInDb.steamId === '76561198000414190'
|
||||
? true
|
||||
: userInDb.isAdmin ?? false
|
||||
|
||||
// ➜ einzig maßgeblich: DB-TimeZone
|
||||
token.isAdmin = userInDb.steamId === '76561198000414190' ? true : userInDb.isAdmin ?? false
|
||||
token.timeZone = userInDb.timeZone ?? undefined
|
||||
} else {
|
||||
token.timeZone = undefined
|
||||
}
|
||||
|
||||
// 🎯 Faceit-Sync ausgelagert
|
||||
if (token.steamId) {
|
||||
await syncFaceitProfile(prisma, token.steamId)
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
if (!token.steamId) throw new Error('steamId is missing in token')
|
||||
|
||||
session.user = {
|
||||
...session.user,
|
||||
steamId: token.steamId,
|
||||
@ -117,23 +99,20 @@ export const authOptions = (req: NextRequest): NextAuthOptions => ({
|
||||
image: token.image,
|
||||
team: token.team ?? null,
|
||||
isAdmin: token.isAdmin ?? false,
|
||||
// ➜ für UI verfügbar
|
||||
timeZone: (token as any).timeZone ?? null,
|
||||
} as typeof session.user & { steamId: string; team: string | null; isAdmin: boolean; timeZone: string | null }
|
||||
|
||||
return session
|
||||
},
|
||||
|
||||
redirect({ url, baseUrl }) {
|
||||
const isSignIn = url.startsWith(`${baseUrl}/api/auth/signin`);
|
||||
const isSignOut = url.startsWith(`${baseUrl}/api/auth/signout`);
|
||||
|
||||
if (isSignOut) return `${baseUrl}/`;
|
||||
if (isSignIn || url === baseUrl) return `${baseUrl}/dashboard`;
|
||||
return url.startsWith(baseUrl) ? url : baseUrl;
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ➕ Base config für `getServerSession()` ohne req
|
||||
// ➕ Base config
|
||||
export const baseAuthOptions: NextAuthOptions = authOptions({} as NextRequest)
|
||||
|
||||
124
src/lib/faceit.ts
Normal file
@ -0,0 +1,124 @@
|
||||
// /src/lib/faceit.ts
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
export type FaceitGame = {
|
||||
region?: string
|
||||
game_player_id?: string
|
||||
game_player_name?: string
|
||||
skill_level?: number
|
||||
faceit_elo?: number
|
||||
skill_level_label?: string
|
||||
game_profile_id?: string
|
||||
}
|
||||
|
||||
export type FaceitPlayer = {
|
||||
player_id: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
country?: string
|
||||
faceit_url?: string
|
||||
verified?: boolean
|
||||
activated_at?: string
|
||||
steam_id_64?: string
|
||||
games?: Record<'cs2' | 'csgo' | string, FaceitGame>
|
||||
}
|
||||
|
||||
/** Nur holen – kein DB-Schreibzugriff */
|
||||
export async function fetchFaceitBySteam64(
|
||||
steam64: string,
|
||||
apiKey = process.env.FACEIT_API_KEY
|
||||
): Promise<FaceitPlayer | null> {
|
||||
if (!apiKey) return null
|
||||
|
||||
const url =
|
||||
`https://open.faceit.com/data/v4/players?game_player_id=${encodeURIComponent(steam64)}&game=cs2`
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) {
|
||||
console.warn('[faceit] lookup failed', res.status, await res.text())
|
||||
return null
|
||||
}
|
||||
return (await res.json()) as FaceitPlayer
|
||||
} catch (e) {
|
||||
console.warn('[faceit] lookup error', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** In DB spiegeln: User-Meta + Game-Stats (cs2/csgo) */
|
||||
export async function upsertFaceitProfile(
|
||||
prisma: PrismaClient,
|
||||
steam64: string,
|
||||
faceit: FaceitPlayer
|
||||
): Promise<void> {
|
||||
// 1) User-Metadaten aktualisieren
|
||||
await prisma.user.update({
|
||||
where: { steamId: steam64 },
|
||||
data: {
|
||||
faceitId: faceit.player_id,
|
||||
faceitNickname: faceit.nickname,
|
||||
faceitAvatar: faceit.avatar ?? undefined,
|
||||
faceitCountry: faceit.country ?? undefined,
|
||||
faceitUrl: faceit.faceit_url ?? undefined,
|
||||
faceitVerified: !!faceit.verified,
|
||||
faceitActivatedAt: faceit.activated_at ? new Date(faceit.activated_at) : undefined,
|
||||
faceitSteamId64: faceit.steam_id_64 ?? steam64,
|
||||
},
|
||||
})
|
||||
|
||||
// 2) Games in FaceitGameStat upserten (nur cs2/csgo)
|
||||
const games = faceit.games ?? {}
|
||||
for (const [gameId, g] of Object.entries(games)) {
|
||||
if (gameId !== 'cs2' && gameId !== 'csgo') continue
|
||||
|
||||
await prisma.faceitGameStat.upsert({
|
||||
where: {
|
||||
// Prisma: @@unique([userSteamId, game])
|
||||
userSteamId_game: { userSteamId: steam64, game: gameId as any },
|
||||
},
|
||||
update: {
|
||||
region: g.region ?? null,
|
||||
gamePlayerId: g.game_player_id ?? null,
|
||||
gamePlayerName: g.game_player_name ?? null,
|
||||
skillLevel: g.skill_level ?? null,
|
||||
elo: g.faceit_elo ?? null,
|
||||
skillLabel: g.skill_level_label ?? null,
|
||||
gameProfileId: g.game_profile_id ?? null,
|
||||
},
|
||||
create: {
|
||||
userSteamId: steam64,
|
||||
game: gameId as any, // 'cs2' | 'csgo' (enum in Prisma)
|
||||
region: g.region ?? null,
|
||||
gamePlayerId: g.game_player_id ?? null,
|
||||
gamePlayerName: g.game_player_name ?? null,
|
||||
skillLevel: g.skill_level ?? null,
|
||||
elo: g.faceit_elo ?? null,
|
||||
skillLabel: g.skill_level_label ?? null,
|
||||
gameProfileId: g.game_profile_id ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Komfortfunktion: holt + spiegelt in DB (no-throw) */
|
||||
export async function syncFaceitProfile(
|
||||
prisma: PrismaClient,
|
||||
steam64: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const faceit = await fetchFaceitBySteam64(steam64)
|
||||
if (!faceit) return
|
||||
await upsertFaceitProfile(prisma, steam64, faceit)
|
||||
} catch (e) {
|
||||
console.warn('[faceit] sync error', e)
|
||||
}
|
||||
}
|
||||