This commit is contained in:
Linrador 2025-10-07 16:34:20 +02:00
commit 5b28bedeb9
42 changed files with 4638 additions and 1488 deletions

12
.env
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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

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

View 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 // 17512000
if (elo >= 1531) return 8 // 15311750
if (elo >= 1351) return 7 // 13511530
if (elo >= 1201) return 6 // 12011350
if (elo >= 1051) return 5 // 10511200
if (elo >= 901) return 4 // 9011050
if (elo >= 751) return 3 // 751900
if (elo >= 501) return 2 // 501750
if (elo >= 100) return 1 // 100500
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' }}
/>
)
}

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&nbsp;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>
)
}
}

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-b999d125698de2af5f56011583f17eaff97910cd6225ae9d3f1438a1592b5997",
"name": "prisma-client-95b977b21ef98eb4d9fe600b3adfad15a11574a50aec075d54017df21315a04c",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",

View 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

File diff suppressed because one or more lines are too long

View File

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

View File

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