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