updated
This commit is contained in:
parent
d7906ad601
commit
caaed1f71e
@ -1,4 +1,5 @@
|
|||||||
// /src/app/components/MatchDetails.tsx
|
// /src/app/[locale]/components/MatchDetails.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
@ -6,14 +7,12 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
|
|
||||||
import Table from './Table'
|
import Table from './Table'
|
||||||
import PremierRankBadge from './PremierRankBadge'
|
import PremierRankBadge from './PremierRankBadge'
|
||||||
import CompRankBadge from './CompRankBadge'
|
import CompRankBadge from './CompRankBadge'
|
||||||
import EditMatchMetaModal from './EditMatchMetaModal'
|
import EditMatchMetaModal from './EditMatchMetaModal'
|
||||||
import EditMatchPlayersModal from './EditMatchPlayersModal'
|
import EditMatchPlayersModal from './EditMatchPlayersModal'
|
||||||
import type { EditSide } from './EditMatchPlayersModal'
|
import type { EditSide } from './EditMatchPlayersModal'
|
||||||
|
|
||||||
import type { Match, MatchPlayer } from '../../../types/match'
|
import type { Match, MatchPlayer } from '../../../types/match'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
@ -45,6 +44,28 @@ type ApiStats = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PrefetchedFaceit = {
|
||||||
|
level: number|null
|
||||||
|
elo: number|null
|
||||||
|
nickname: string|null
|
||||||
|
url: string|null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiMatchStat = {
|
||||||
|
date: string; kills: number; deaths: number; assists?: number|null; totalDamage?: number|null; rounds?: number|null
|
||||||
|
}
|
||||||
|
type ApiResponse = {
|
||||||
|
user?: {
|
||||||
|
steamId?: string|null
|
||||||
|
faceit?: {
|
||||||
|
faceitUrl?: string|null
|
||||||
|
faceitNickname?: string|null
|
||||||
|
faceitGames?: Array<{ skillLevel: number|null; elo: number|null }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats: Array<ApiMatchStat>
|
||||||
|
}
|
||||||
|
|
||||||
function perfOfMatchPrefetch(m: { 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 k = m.kills ?? 0
|
||||||
const d = m.deaths ?? 0
|
const d = m.deaths ?? 0
|
||||||
@ -61,14 +82,19 @@ function perfOfMatchPrefetch(m: { kills?: number; deaths?: number; assists?: num
|
|||||||
return 0.45 * kdS + 0.45 * adrS + 0.10 * (0.7 * kprS + 0.3 * aprS)
|
return 0.45 * kdS + 0.45 * adrS + 0.10 * (0.7 * kprS + 0.3 * aprS)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPlayerSummary(steamId: string): Promise<PlayerSummary | null> {
|
async function buildPlayerSummaryAndFaceit(steamId: string): Promise<{
|
||||||
|
summary: PlayerSummary | null
|
||||||
|
faceit: PrefetchedFaceit | null
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const base = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
const base = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
||||||
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store', credentials: 'include' })
|
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store', credentials: 'include' })
|
||||||
if (!res.ok) return null
|
if (!res.ok) return { summary: null, faceit: null }
|
||||||
const data = (await res.json()) as ApiStats
|
|
||||||
|
const data = (await res.json()) as ApiResponse
|
||||||
const matches = Array.isArray(data?.stats) ? data.stats : []
|
const matches = Array.isArray(data?.stats) ? data.stats : []
|
||||||
|
|
||||||
|
// Summary wie gehabt
|
||||||
const games = matches.length
|
const games = matches.length
|
||||||
const kills = matches.reduce((s, m) => s + (m.kills ?? 0), 0)
|
const kills = matches.reduce((s, m) => s + (m.kills ?? 0), 0)
|
||||||
const deaths = matches.reduce((s, m) => s + (m.deaths ?? 0), 0)
|
const deaths = matches.reduce((s, m) => s + (m.deaths ?? 0), 0)
|
||||||
@ -76,19 +102,29 @@ async function buildPlayerSummary(steamId: string): Promise<PlayerSummary | null
|
|||||||
const kd = deaths > 0 ? kills / deaths : Infinity
|
const kd = deaths > 0 ? kills / deaths : Infinity
|
||||||
const avgDmgPerMatch = games ? dmg / games : 0
|
const avgDmgPerMatch = games ? dmg / games : 0
|
||||||
const avgKillsPerMatch = games ? kills / 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 sorted = [...matches].sort((a,b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
const last10 = sorted.slice(-10)
|
||||||
const last10 = sorted.slice(-10)
|
|
||||||
const perfSeries = last10.length ? last10.map(perfOfMatchPrefetch) : [0,0]
|
const perfSeries = last10.length ? last10.map(perfOfMatchPrefetch) : [0,0]
|
||||||
const lastPerf = perfSeries.at(-1) ?? 0
|
const lastPerf = perfSeries.at(-1) ?? 0
|
||||||
const prevPerf = perfSeries.length > 1
|
const prevPerf = perfSeries.length > 1 ? perfSeries.slice(0,-1).reduce((a,b)=>a+b,0)/(perfSeries.length-1) : lastPerf
|
||||||
? perfSeries.slice(0, -1).reduce((a,b)=>a+b,0) / (perfSeries.length - 1)
|
const perfDelta = perfSeries.length > 1 ? (lastPerf - prevPerf) : 0
|
||||||
: lastPerf
|
const summary: PlayerSummary = { games, kd, avgDmgPerMatch, avgKillsPerMatch, perfDelta, perfSeries }
|
||||||
const perfDelta = perfSeries.length > 1 ? (lastPerf - prevPerf) : 0
|
|
||||||
|
|
||||||
return { games, kd, avgDmgPerMatch, avgKillsPerMatch, perfDelta, perfSeries }
|
// FACEIT aus API
|
||||||
|
const lvl = data.user?.faceit?.faceitGames?.[0]?.skillLevel ?? null
|
||||||
|
const elo = data.user?.faceit?.faceitGames?.[0]?.elo ?? null
|
||||||
|
const nick = data.user?.faceit?.faceitNickname ?? null
|
||||||
|
const url = data.user?.faceit?.faceitUrl
|
||||||
|
? data.user.faceit.faceitUrl.replace('{lang}', 'en')
|
||||||
|
: (nick ? `https://www.faceit.com/en/players/${encodeURIComponent(nick)}` : null)
|
||||||
|
|
||||||
|
const faceit: PrefetchedFaceit | null = (lvl || elo || nick || url) ? {
|
||||||
|
level: lvl, elo, nickname: nick, url
|
||||||
|
} : null
|
||||||
|
|
||||||
|
return { summary, faceit }
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return { summary: null, faceit: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,6 +384,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
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>>({})
|
const [playerSummaries, setPlayerSummaries] = useState<Record<string, PlayerSummary | null>>({})
|
||||||
|
const [playerFaceits, setPlayerFaceits] = useState<Record<string, PrefetchedFaceit | 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>(() =>
|
||||||
@ -367,7 +404,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
||||||
|
|
||||||
const [hoverPlayer, setHoverPlayer] = useState<MatchPlayer | null>(null)
|
const [hoverPlayer, setHoverPlayer] = useState<MatchPlayer | null>(null)
|
||||||
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null)
|
|
||||||
|
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||||
const cardElRef = useRef<HTMLDivElement | null>(null)
|
const cardElRef = useRef<HTMLDivElement | null>(null)
|
||||||
@ -377,6 +413,8 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
const [leadOverride, setLeadOverride] = useState<number | null>(null)
|
const [leadOverride, setLeadOverride] = useState<number | null>(null)
|
||||||
const lastHandledKeyRef = useRef<string>('')
|
const lastHandledKeyRef = useRef<string>('')
|
||||||
|
|
||||||
|
const hoverTimer = useRef<number | null>(null)
|
||||||
|
|
||||||
// Rollen & Rechte
|
// Rollen & Rechte
|
||||||
const me = session?.user
|
const me = session?.user
|
||||||
const userId = me?.steamId
|
const userId = me?.steamId
|
||||||
@ -404,76 +442,37 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
const currentMapKey = normalizeMapKey(match.map)
|
const currentMapKey = normalizeMapKey(match.map)
|
||||||
|
|
||||||
useEffect(() => {
|
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 ?? [])]
|
const ids = [...(match.teamA?.players ?? []), ...(match.teamB?.players ?? [])]
|
||||||
.map(p => p.user?.steamId)
|
.map(p => p.user?.steamId)
|
||||||
.filter((id): id is string => !!id)
|
.filter((id): id is string => !!id)
|
||||||
|
if (!ids.length) return
|
||||||
if (ids.length === 0) return
|
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
// nur noch laden, was wir noch nicht haben
|
const idsToFetch = ids.filter(id => !(id in playerSummaries) || !(id in playerFaceits))
|
||||||
const idsToFetch = ids.filter(id => !(id in playerSummaries))
|
if (!idsToFetch.length) return
|
||||||
if (idsToFetch.length === 0) return
|
|
||||||
|
|
||||||
const results = await Promise.all(idsToFetch.map(async (id) => {
|
const results = await Promise.all(idsToFetch.map(async (id) => {
|
||||||
const summary = await buildPlayerSummary(id)
|
const both = await buildPlayerSummaryAndFaceit(id)
|
||||||
return [id, summary] as const
|
return [id, both] as const
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setPlayerSummaries(prev => {
|
setPlayerSummaries(prev => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
for (const [id, sum] of results) next[id] = sum
|
for (const [id, {summary}] of results) next[id] = summary
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setPlayerFaceits(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const [id, {faceit}] of results) next[id] = faceit
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [match.teamA?.players, match.teamB?.players])
|
}, [match.teamA?.players, match.teamB?.players])
|
||||||
|
|
||||||
// beim mount user-tz aus DB laden
|
// beim mount user-tz aus DB laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -677,21 +676,18 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
return (
|
return (
|
||||||
<Table.Row key={p.user.steamId} title={title} className={`${banned ? 'bg-red-900/20' : ''}`}>
|
<Table.Row key={p.user.steamId} title={title} className={`${banned ? 'bg-red-900/20' : ''}`}>
|
||||||
<Table.Cell
|
<Table.Cell
|
||||||
className={`flex items-center`}
|
className="flex items-center"
|
||||||
hoverable
|
hoverable
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
const el = e.currentTarget as HTMLElement
|
||||||
setHoverPlayer(p)
|
setHoverPlayer(p)
|
||||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
setAnchorEl(el)
|
||||||
setAnchorEl(e.currentTarget as HTMLElement)
|
|
||||||
}}
|
}}
|
||||||
onMouseMove={(e) => {
|
onMouseLeave={() => {
|
||||||
if (hoverPlayer?.user.steamId === p.user.steamId) {
|
if (hoverTimer.current) { window.clearTimeout(hoverTimer.current); hoverTimer.current = null }
|
||||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
setHoverPlayer(p)
|
setHoverPlayer(p)
|
||||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
|
||||||
setAnchorEl(e.currentTarget as HTMLElement)
|
setAnchorEl(e.currentTarget as HTMLElement)
|
||||||
}}
|
}}
|
||||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||||
@ -966,12 +962,12 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
|
|
||||||
{/* Teams / Tabellen */}
|
{/* Teams / Tabellen */}
|
||||||
{/* Team A */}
|
{/* Team A */}
|
||||||
<div className="mt-4">
|
<div>
|
||||||
{renderTable(teamAPlayers, teamATitle, showEditA, () => setEditSide('A'))}
|
{renderTable(teamAPlayers, teamATitle, showEditA, () => setEditSide('A'))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team B */}
|
{/* Team B */}
|
||||||
<div className="mt-4">
|
<div>
|
||||||
{renderTable(teamBPlayers, teamBTitle, showEditB, () => setEditSide('B'))}
|
{renderTable(teamBPlayers, teamBTitle, showEditB, () => setEditSide('B'))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1018,9 +1014,17 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
<MiniPlayerCard
|
<MiniPlayerCard
|
||||||
open={!!hoverPlayer}
|
open={!!hoverPlayer}
|
||||||
player={hoverPlayer}
|
player={hoverPlayer}
|
||||||
anchor={hoverRect}
|
anchor={null}
|
||||||
onClose={() => { setHoverPlayer(null); setHoverRect(null); setAnchorEl(null) }}
|
onClose={() => {
|
||||||
|
// Nicht schließen, wenn entweder Anchor ODER die Card selbst gerade gehovert ist
|
||||||
|
const anchorHovered = !!anchorEl?.matches(':hover')
|
||||||
|
const cardHovered = !!cardElRef.current?.matches?.(':hover')
|
||||||
|
if (anchorHovered || cardHovered) return
|
||||||
|
setHoverPlayer(null)
|
||||||
|
setAnchorEl(null)
|
||||||
|
}}
|
||||||
prefetchedSummary={hoverPlayer.user?.steamId ? playerSummaries[hoverPlayer.user.steamId] ?? null : null}
|
prefetchedSummary={hoverPlayer.user?.steamId ? playerSummaries[hoverPlayer.user.steamId] ?? null : null}
|
||||||
|
prefetchedFaceit={hoverPlayer.user?.steamId ? playerFaceits[hoverPlayer.user.steamId] ?? null : null}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onCardMount={(el) => { cardElRef.current = el }}
|
onCardMount={(el) => { cardElRef.current = el }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -4,10 +4,11 @@
|
|||||||
|
|
||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useRouter } from '@/i18n/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { MatchPlayer } from '../../../types/match'
|
import type { MatchPlayer } from '../../../types/match'
|
||||||
import PremierRankBadge from './PremierRankBadge'
|
import PremierRankBadge from './PremierRankBadge'
|
||||||
import CompRankBadge from './CompRankBadge'
|
import FaceitLevelImage from './FaceitLevelBadge'
|
||||||
|
|
||||||
export type MiniPlayerCardProps = {
|
export type MiniPlayerCardProps = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -15,42 +16,35 @@ export type MiniPlayerCardProps = {
|
|||||||
anchor: DOMRect | null
|
anchor: DOMRect | null
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
prefetchedSummary?: PlayerSummary | null
|
prefetchedSummary?: PlayerSummary | null
|
||||||
/** echtes Anchor-Element (für Hover-Containment im Parent) */
|
prefetchedFaceit?: { level: number|null; elo: number|null; nickname: string|null; url: string|null } | null
|
||||||
anchorEl?: HTMLElement | null
|
anchorEl?: HTMLElement | null
|
||||||
/** Card-Element an Parent melden */
|
|
||||||
onCardMount?: (el: HTMLDivElement | null) => void
|
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 = {
|
type UserWithFaceit = {
|
||||||
steamId?: string | null
|
steamId?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
avatar?: string | null
|
avatar?: string | null
|
||||||
premierRank?: number | null
|
premierRank?: number | null
|
||||||
// Ban (flat – wie in ProfileHeader)
|
|
||||||
vacBanned?: boolean | null
|
vacBanned?: boolean | null
|
||||||
numberOfVACBans?: number | null
|
numberOfVACBans?: number | null
|
||||||
numberOfGameBans?: number | null
|
numberOfGameBans?: number | null
|
||||||
communityBanned?: boolean | null
|
communityBanned?: boolean | null
|
||||||
economyBan?: string | null
|
economyBan?: string | null
|
||||||
daysSinceLastBan?: number | null
|
daysSinceLastBan?: number | null
|
||||||
// FACEIT (flat – wie in ProfileHeader)
|
|
||||||
faceitNickname?: string | null
|
faceitNickname?: string | null
|
||||||
faceitUrl?: string | null
|
faceitUrl?: string | null
|
||||||
faceitLevel?: number | null
|
faceitLevel?: number | null
|
||||||
faceitElo?: number | null
|
faceitElo?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FaceitState = {
|
||||||
|
level: number | null
|
||||||
|
elo: number | null
|
||||||
|
nickname: string | null
|
||||||
|
url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
/** gleiche Struktur wie in MatchDetails */
|
/** gleiche Struktur wie in MatchDetails */
|
||||||
export type PlayerSummary = {
|
export type PlayerSummary = {
|
||||||
games: number
|
games: number
|
||||||
@ -61,39 +55,8 @@ export type PlayerSummary = {
|
|||||||
perfSeries: 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[] }) {
|
function Sparkline({ values }: { values: number[] }) {
|
||||||
const W = 200, H = 40, pad = 6
|
const W = 200, H = 40, pad = 6, n = Math.max(1, values.length)
|
||||||
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 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 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(' ')
|
const pts = values.map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`).join(' ')
|
||||||
@ -105,319 +68,265 @@ function Sparkline({ values }: { values: number[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MiniPlayerCard({
|
export default function MiniPlayerCard({
|
||||||
open, player, anchor, onClose, prefetchedSummary, anchorEl, onCardMount
|
open, player, anchor, onClose, prefetchedSummary, prefetchedFaceit, anchorEl, onCardMount
|
||||||
}: MiniPlayerCardProps) {
|
}: MiniPlayerCardProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({ top: 0, left: 0, side: 'right' })
|
||||||
|
const [measured, setMeasured] = useState(false)
|
||||||
|
|
||||||
// Position-State
|
// Hover-Intent
|
||||||
const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({
|
const openT = useRef<number | null>(null)
|
||||||
top: 0, left: 0, side: 'right'
|
const closeT = useRef<number | null>(null)
|
||||||
})
|
const OPEN_DELAY = 120
|
||||||
|
const CLOSE_DELAY = 160
|
||||||
|
|
||||||
// Profil-Summary/SteamId + Lade-State
|
// User/Faceit
|
||||||
const u = (player.user ?? {}) as UserWithFaceit
|
const u = (player.user ?? {}) as UserWithFaceit
|
||||||
const steam64 = u.steamId ?? null
|
const steam64 = u.steamId ?? null
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
// Summary nur aus Prefetch (kein Fetch)
|
||||||
const [summary, setSummary] = useState<PlayerSummary | null>(prefetchedSummary ?? null)
|
const [summary, setSummary] = useState<PlayerSummary | null>(prefetchedSummary ?? null)
|
||||||
|
useEffect(() => { setSummary(prefetchedSummary ?? null) }, [prefetchedSummary, player.user?.steamId])
|
||||||
|
|
||||||
// FACEIT-Werte direkt aus user-Props (wie im ProfileHeader)
|
// FACEIT aus Prefetch / user-Fallback
|
||||||
const faceitLevel = u.faceitLevel ?? null
|
const faceit = useMemo<FaceitState>(() => {
|
||||||
const faceitElo = u.faceitElo ?? null
|
const url =
|
||||||
const faceitNick = u.faceitNickname ?? null
|
prefetchedFaceit?.url
|
||||||
const faceitUrl = u.faceitUrl
|
?? (u.faceitUrl ? u.faceitUrl.replace('{lang}', 'en')
|
||||||
? u.faceitUrl.replace('{lang}', 'en')
|
: (u.faceitNickname ? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}` : null))
|
||||||
: (faceitNick ? `https://www.faceit.com/en/players/${encodeURIComponent(faceitNick)}` : null)
|
|
||||||
|
|
||||||
// Outside-Click + ESC schließen
|
return {
|
||||||
useEffect(() => {
|
level: prefetchedFaceit?.level ?? u.faceitLevel ?? null,
|
||||||
if (!open) return
|
elo: prefetchedFaceit?.elo ?? u.faceitElo ?? null,
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose?.() }
|
nickname: prefetchedFaceit?.nickname ?? u.faceitNickname ?? null,
|
||||||
const onDown = (e: MouseEvent) => {
|
url,
|
||||||
if (!cardRef.current) return
|
|
||||||
if (!cardRef.current.contains(e.target as Node)) onClose?.()
|
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
}, [prefetchedFaceit, u.faceitUrl, u.faceitNickname, u.faceitLevel, u.faceitElo])
|
||||||
document.addEventListener('mousedown', onDown)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', onKey)
|
|
||||||
document.removeEventListener('mousedown', onDown)
|
|
||||||
}
|
|
||||||
}, [open, onClose])
|
|
||||||
|
|
||||||
const setCardRef = (el: HTMLDivElement | null) => {
|
const setRef = (el: HTMLDivElement | null) => {
|
||||||
cardRef.current = el
|
cardRef.current = el
|
||||||
onCardMount?.(el) // Parent informieren
|
onCardMount?.(el)
|
||||||
if (open) schedulePosition()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats laden, wenn kein Prefetch
|
// Positionierung
|
||||||
useEffect(() => {
|
const doPosition = () => {
|
||||||
if (!open || !steam64) return
|
if (!cardRef.current || !anchorEl) return
|
||||||
if (prefetchedSummary) { setSummary(prefetchedSummary); setLoading(false); return }
|
const a = anchorEl.getBoundingClientRect()
|
||||||
|
|
||||||
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 cardEl = cardRef.current
|
||||||
const vw = window.innerWidth
|
const vw = window.innerWidth, vh = window.innerHeight
|
||||||
const vh = window.innerHeight
|
|
||||||
|
|
||||||
const prevVis = cardEl.style.visibility
|
|
||||||
cardEl.style.visibility = 'hidden'
|
|
||||||
const { width: cw, height: ch } = cardEl.getBoundingClientRect()
|
const { width: cw, height: ch } = cardEl.getBoundingClientRect()
|
||||||
|
|
||||||
// KEIN GAP: direkt an die Table-Cell andocken
|
const rightLeft = a.right
|
||||||
const rightLeft = anchor.right
|
const leftLeft = a.left - cw
|
||||||
const leftLeft = anchor.left - cw
|
|
||||||
const fitsRight = rightLeft + cw <= vw
|
const fitsRight = rightLeft + cw <= vw
|
||||||
const fitsLeft = leftLeft >= 8
|
const fitsLeft = leftLeft >= 8
|
||||||
const side: 'right' | 'left' = fitsRight || !fitsLeft ? 'right' : 'left'
|
const side: 'right' | 'left' = fitsRight || !fitsLeft ? 'right' : 'left'
|
||||||
const left = side === 'right' ? Math.min(rightLeft, vw - cw - 8) : Math.max(8, leftLeft)
|
const left = side === 'right' ? Math.min(rightLeft, vw - cw - 8) : Math.max(8, leftLeft)
|
||||||
|
|
||||||
const topRaw = anchor.top + (anchor.height - ch) / 2
|
const topRaw = a.top + (a.height - ch) / 2
|
||||||
const top = clamp(topRaw, 8, vh - ch - 8)
|
const top = Math.max(8, Math.min(topRaw, vh - ch - 8))
|
||||||
|
|
||||||
setPos({ top: Math.round(top), left: Math.round(left), side })
|
setPos({ top: Math.round(top), left: Math.round(left), side })
|
||||||
cardEl.style.visibility = prevVis
|
setMeasured(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- BAN-Badges -----
|
const schedule = () => requestAnimationFrame(() => requestAnimationFrame(doPosition))
|
||||||
// 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
|
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open) { setMeasured(false); schedule() }
|
||||||
|
}, [open, anchorEl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onScrollOrResize = () => schedule()
|
||||||
|
window.addEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.addEventListener('resize', onScrollOrResize)
|
||||||
|
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
if (anchorEl && 'ResizeObserver' in window) {
|
||||||
|
ro = new ResizeObserver(() => schedule())
|
||||||
|
ro.observe(anchorEl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.removeEventListener('resize', onScrollOrResize)
|
||||||
|
ro?.disconnect()
|
||||||
|
}
|
||||||
|
}, [open, anchorEl])
|
||||||
|
|
||||||
|
// Bei Spielerwechsel sanft neu einmessen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (closeT.current) {
|
||||||
|
window.clearTimeout(closeT.current)
|
||||||
|
closeT.current = null
|
||||||
|
}
|
||||||
|
setMeasured(false)
|
||||||
|
schedule()
|
||||||
|
}, [open, anchorEl, player.user?.steamId])
|
||||||
|
|
||||||
|
// Anchor-Hover steuert Open/Close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anchorEl) return
|
||||||
|
|
||||||
|
const armClose = () => {
|
||||||
|
if (!closeT.current) {
|
||||||
|
closeT.current = window.setTimeout(() => { closeT.current = null; onClose?.() }, CLOSE_DELAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const disarmClose = () => {
|
||||||
|
if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
disarmClose()
|
||||||
|
if (!open && !openT.current) {
|
||||||
|
openT.current = window.setTimeout(() => { openT.current = null }, OPEN_DELAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onLeave = () => {
|
||||||
|
if (openT.current) { window.clearTimeout(openT.current); openT.current = null }
|
||||||
|
armClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
anchorEl.addEventListener('mouseenter', onEnter)
|
||||||
|
anchorEl.addEventListener('mouseleave', onLeave)
|
||||||
|
anchorEl.addEventListener('focus', onEnter, true)
|
||||||
|
anchorEl.addEventListener('blur', onLeave, true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
anchorEl.removeEventListener('mouseenter', onEnter)
|
||||||
|
anchorEl.removeEventListener('mouseleave', onLeave)
|
||||||
|
anchorEl.removeEventListener('focus', onEnter, true)
|
||||||
|
anchorEl.removeEventListener('blur', onLeave, true)
|
||||||
|
}
|
||||||
|
}, [anchorEl, open, onClose])
|
||||||
|
|
||||||
|
// BAN-Badges
|
||||||
|
const nestedBan = (player.user as any)?.banStatus
|
||||||
|
const flat = u
|
||||||
const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0)
|
const hasVacNested = !!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0)
|
||||||
const isBannedNested =
|
const isBannedNested =
|
||||||
!!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 ||
|
!!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 ||
|
||||||
(nestedBan?.numberOfGameBans ?? 0) > 0 || nestedBan?.communityBanned ||
|
(nestedBan?.numberOfGameBans ?? 0) > 0 || nestedBan?.communityBanned ||
|
||||||
(nestedBan?.economyBan && nestedBan.economyBan !== 'none'))
|
(nestedBan?.economyBan && nestedBan.economyBan !== 'none'))
|
||||||
|
|
||||||
const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0
|
const hasVacFlat = !!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0
|
||||||
const isBannedFlat =
|
const isBannedFlat =
|
||||||
!!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 || (flat.numberOfGameBans ?? 0) > 0 ||
|
!!flat.vacBanned || (flat.numberOfVACBans ?? 0) > 0 || (flat.numberOfGameBans ?? 0) > 0 ||
|
||||||
!!flat.communityBanned || (!!flat.economyBan && flat.economyBan !== 'none')
|
!!flat.communityBanned || (!!flat.economyBan && flat.economyBan !== 'none')
|
||||||
|
|
||||||
const hasVac = nestedBan ? hasVacNested : hasVacFlat
|
const hasVac = nestedBan ? hasVacNested : hasVacFlat
|
||||||
const isBanned = nestedBan ? isBannedNested : isBannedFlat
|
const isBanned = nestedBan ? isBannedNested : isBannedFlat
|
||||||
|
|
||||||
const banTooltip = useMemo(() => {
|
const banTooltip = useMemo(() => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (nestedBan) {
|
const src = nestedBan ?? flat
|
||||||
if (nestedBan.vacBanned) parts.push('VAC-Ban aktiv')
|
if (src.vacBanned) parts.push('VAC-Ban aktiv')
|
||||||
if ((nestedBan.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${nestedBan.numberOfVACBans}`)
|
if ((src.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${src.numberOfVACBans}`)
|
||||||
if ((nestedBan.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${nestedBan.numberOfGameBans}`)
|
if ((src.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${src.numberOfGameBans}`)
|
||||||
if (nestedBan.communityBanned) parts.push('Community-Ban')
|
if (src.communityBanned) parts.push('Community-Ban')
|
||||||
if (nestedBan.economyBan && nestedBan.economyBan !== 'none') parts.push(`Economy: ${nestedBan.economyBan}`)
|
if (src.economyBan && src.economyBan !== 'none') parts.push(`Economy: ${src.economyBan}`)
|
||||||
if (typeof nestedBan.daysSinceLastBan === 'number') parts.push(`Tage seit letztem Ban: ${nestedBan.daysSinceLastBan}`)
|
if (typeof src.daysSinceLastBan === 'number') parts.push(`Tage seit letztem Ban: ${src.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(' · ')
|
return parts.join(' · ')
|
||||||
}, [nestedBan, flat])
|
}, [nestedBan, flat])
|
||||||
|
|
||||||
if (!open || typeof window === 'undefined') return null
|
if (!open) 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
|
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 = (
|
const body = (
|
||||||
<div
|
<div
|
||||||
ref={setCardRef}
|
ref={setRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={`Spielerinfo ${u.name ?? ''}`}
|
aria-label={`Spielerinfo ${u.name ?? ''}`}
|
||||||
tabIndex={-1}
|
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"
|
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 transition-opacity duration-100"
|
||||||
style={{ top: pos.top, left: pos.left }}
|
style={{ top: pos.top, left: pos.left, opacity: measured ? 1 : 0 }}
|
||||||
// WICHTIG: kein onMouseLeave/onBlur → Card bleibt zum Klicken offen
|
onMouseEnter={() => { if (closeT.current) { window.clearTimeout(closeT.current); closeT.current = null } }}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (!closeT.current) {
|
||||||
|
closeT.current = window.setTimeout(() => { closeT.current = null; onClose?.() }, CLOSE_DELAY)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* 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 */}
|
{/* Pfeil */}
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={[
|
className={[
|
||||||
'absolute h-3 w-3 rotate-45 border border-white/10 bg-neutral-900/95',
|
'absolute h-3 w-3 rotate-45 border border-white/10 bg-neutral-900/95',
|
||||||
pos.side === 'right' ? '-left-1.5' : '-right-1.5'
|
pos.side === 'right' ? '-left-1.5' : '-right-1.5',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={{ top: 'calc(50% - 6px)' }}
|
style={{ top: 'calc(50% - 6px)' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Header: Avatar + Name + Rank + BAN + FACEIT */}
|
{/* Header mit Links rechts */}
|
||||||
<div className="flex items-center gap-3">
|
<div
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
onClick={() => { steam64 ? router.push(`/profile/${steam64}`) : null }}
|
||||||
<img
|
className="flex cursor-pointer transition bg-white dark:bg-neutral-800 dark:border-neutral-700 hover:bg-neutral-200 hover:dark:bg-neutral-700 items-center justify-between mb-2 rounded-md bg-white/5 ring-1 ring-white/10 px-2 py-2"
|
||||||
src={u.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
>
|
||||||
alt={u.name || 'Avatar'}
|
{/* Links: Avatar + Name + Badges */}
|
||||||
className="h-12 w-12 rounded-full ring-1 ring-white/15"
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
/>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<div className="min-w-0">
|
<img
|
||||||
{/* Name → eigenes Profil */}
|
src={u.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
||||||
<div className="truncate text-sm font-semibold">
|
alt={u.name || 'Avatar'}
|
||||||
<Link href={steam64 ? `/profile/${steam64}` : '#'} className="hover:underline">
|
className="h-12 w-12 rounded-full ring-1 ring-white/15"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="truncate text-sm font-semibold">
|
||||||
{u.name ?? 'Unbekannt'}
|
{u.name ?? 'Unbekannt'}
|
||||||
|
</div>
|
||||||
|
{/* darunter: Premier + Faceit + Ban */}
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<PremierRankBadge rank={u.premierRank ?? 0} />
|
||||||
|
{faceit.nickname && <FaceitLevelImage elo={faceit.elo ?? 0} className="-ml-0.5" />}
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Rechts: externe Links */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{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>
|
</Link>
|
||||||
</div>
|
)}
|
||||||
|
{faceit.url && (
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<Link
|
||||||
{/* Rank */}
|
href={faceit.url}
|
||||||
{typeof (u.premierRank ?? player.stats?.rankNew) === 'number' ? (
|
target="_blank"
|
||||||
<div className="flex items-center gap-1">
|
rel="noopener noreferrer"
|
||||||
<PremierRankBadge rank={u.premierRank ?? player.stats?.rankNew ?? 0} />
|
aria-label="FACEIT-Profil öffnen"
|
||||||
{rankChange !== null && (
|
title={`Faceit-Profil${faceit.nickname ? ` von ${faceit.nickname}` : ''}`}
|
||||||
<span className={[
|
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"
|
||||||
'text-[11px] tabular-nums font-semibold',
|
>
|
||||||
rankChange > 0 ? 'text-emerald-300' : rankChange < 0 ? 'text-rose-300' : 'text-neutral-300'
|
<img src="/assets/img/logos/faceit.svg" alt="" className="h-4 w-4" aria-hidden />
|
||||||
].join(' ')}>
|
</Link>
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Mini-Profil / Stats */}
|
||||||
<div className="my-3 h-px bg-white/10" />
|
|
||||||
|
|
||||||
{/* Mini-Profil */}
|
|
||||||
<div className="space-y-2">
|
<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 && (
|
{summary && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@ -430,10 +339,12 @@ export default function MiniPlayerCard({
|
|||||||
<div className="rounded-md bg-white/5 ring-1 ring-white/10 px-2 py-2">
|
<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="flex items-center justify-between">
|
||||||
<div className="text-[11px] uppercase tracking-wide text-white/60">Performance</div>
|
<div className="text-[11px] uppercase tracking-wide text-white/60">Performance</div>
|
||||||
<div className={[
|
<div
|
||||||
'text-[11px] font-medium',
|
className={[
|
||||||
summary.perfDelta > 0 ? 'text-emerald-300' : summary.perfDelta < 0 ? 'text-rose-300' : 'text-neutral-300'
|
'text-[11px] font-medium',
|
||||||
].join(' ')}>
|
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)}`}
|
{summary.perfDelta === 0 ? '±0.00' : `${summary.perfDelta > 0 ? '+' : ''}${summary.perfDelta.toFixed(2)}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -447,7 +358,8 @@ export default function MiniPlayerCard({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return createPortal(body, document.body)
|
const target = document.getElementById('__next') || document.body
|
||||||
|
return createPortal(body, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- kleine UI-Bausteine --- */
|
/* --- kleine UI-Bausteine --- */
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
// Tabs.tsx
|
// /src/app/[locale]/components/Tabs.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
@ -15,13 +16,19 @@ type TabsProps = {
|
|||||||
/** optional kontrollierter Modus */
|
/** optional kontrollierter Modus */
|
||||||
value?: string
|
value?: string
|
||||||
onChange?: (name: string) => void
|
onChange?: (name: string) => void
|
||||||
/** neu: Ausrichtung */
|
/** Ausrichtung */
|
||||||
orientation?: 'horizontal' | 'vertical'
|
orientation?: 'horizontal' | 'vertical'
|
||||||
/** optional: Styling */
|
/** optional: Styling */
|
||||||
className?: string
|
className?: string
|
||||||
tabClassName?: string
|
tabClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalize(path: string) {
|
||||||
|
if (!path) return '/'
|
||||||
|
const v = path.replace(/\/+$/, '')
|
||||||
|
return v === '' ? '/' : v
|
||||||
|
}
|
||||||
|
|
||||||
export function Tabs({
|
export function Tabs({
|
||||||
children,
|
children,
|
||||||
value,
|
value,
|
||||||
@ -31,8 +38,23 @@ export function Tabs({
|
|||||||
tabClassName = ''
|
tabClassName = ''
|
||||||
}: TabsProps) {
|
}: TabsProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const tabs = Array.isArray(children) ? children : [children]
|
|
||||||
|
// Kinder in gültige Tab-Elemente filtern
|
||||||
|
const rawTabs = Array.isArray(children) ? children : [children]
|
||||||
|
const tabs = rawTabs.filter(
|
||||||
|
(tab): tab is ReactElement<TabProps> =>
|
||||||
|
tab !== null &&
|
||||||
|
typeof tab === 'object' &&
|
||||||
|
'props' in tab &&
|
||||||
|
typeof tab.props.href === 'string' &&
|
||||||
|
typeof tab.props.name === 'string'
|
||||||
|
)
|
||||||
|
|
||||||
const isVertical = orientation === 'vertical'
|
const isVertical = orientation === 'vertical'
|
||||||
|
const current = normalize(pathname)
|
||||||
|
|
||||||
|
// Liste aller Tab-URLs (normalisiert) für die Heuristik
|
||||||
|
const hrefs = tabs.map(t => normalize(t.props.href))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@ -45,50 +67,18 @@ export function Tabs({
|
|||||||
role="tablist"
|
role="tablist"
|
||||||
aria-orientation={isVertical ? 'vertical' : 'horizontal'}
|
aria-orientation={isVertical ? 'vertical' : 'horizontal'}
|
||||||
>
|
>
|
||||||
{tabs
|
{tabs.map((tab, index) => {
|
||||||
.filter(
|
const baseClasses =
|
||||||
(tab): tab is ReactElement<TabProps> =>
|
'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
|
||||||
tab !== null &&
|
|
||||||
typeof tab === 'object' &&
|
|
||||||
'props' in tab &&
|
|
||||||
typeof tab.props.href === 'string'
|
|
||||||
)
|
|
||||||
.map((tab, index) => {
|
|
||||||
const baseClasses =
|
|
||||||
'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
|
|
||||||
|
|
||||||
// kontrollierter Modus
|
|
||||||
if (onChange && value !== undefined) {
|
|
||||||
const isActive = value === tab.props.name
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(tab.props.name)}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
className={
|
|
||||||
baseClasses +
|
|
||||||
' ' +
|
|
||||||
(isActive
|
|
||||||
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
|
||||||
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tab.props.name}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link-basiert
|
|
||||||
const base = tab.props.href.replace(/\/$/, '')
|
|
||||||
const current = pathname.replace(/\/$/, '')
|
|
||||||
const isActive = current === base || current.startsWith(base + '/')
|
|
||||||
|
|
||||||
|
// Kontrollierter Modus: Auswahl über value/onChange
|
||||||
|
if (onChange && value !== undefined) {
|
||||||
|
const isActive = value === tab.props.name
|
||||||
return (
|
return (
|
||||||
<Link
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
href={tab.props.href}
|
type="button"
|
||||||
|
onClick={() => onChange(tab.props.name)}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
className={
|
className={
|
||||||
@ -100,11 +90,44 @@ export function Tabs({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{tab.props.name}
|
{tab.props.name}
|
||||||
</Link>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
// Unkontrollierter Modus: Link-basiert
|
||||||
|
const base = normalize(tab.props.href)
|
||||||
|
|
||||||
|
// Hat dieser Tab "tiefere" Geschwister? (z.B. /profile/... und /profile/.../matches)
|
||||||
|
const hasSiblingDeeper = hrefs.some(h => h !== base && h.startsWith(base + '/'))
|
||||||
|
|
||||||
|
// Nur wenn es KEINEN tieferen Sibling gibt, erlauben wir Prefix-Matching.
|
||||||
|
// Dadurch ist /profile/... NICHT aktiv, wenn /profile/.../matches aktiv ist.
|
||||||
|
const allowStartsWith = !hasSiblingDeeper
|
||||||
|
|
||||||
|
const isActive = current === base || (allowStartsWith && current.startsWith(base + '/'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={index}
|
||||||
|
href={tab.props.href}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
className={
|
||||||
|
baseClasses +
|
||||||
|
' ' +
|
||||||
|
(isActive
|
||||||
|
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
||||||
|
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.props.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Tabs.Tab = function Tab(_props: TabProps) { return null }
|
Tabs.Tab = function Tab(_props: TabProps) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
// /src/app/profile/[steamId]/ProfileHeader.tsx
|
// /src/app/profile/[steamId]/ProfileHeader.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Tabs } from '../../components/Tabs'
|
import { Tabs } from '../../components/Tabs'
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// /src/app/api/stats/[steamId]/route.ts
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
@ -12,28 +13,52 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Hole den User (nur die nötigsten Felder)
|
// User (nur nötige Felder) – Faceit-Daten werden in ein verschachteltes Objekt gemappt
|
||||||
const user = await prisma.user.findUnique({
|
const userRaw = await prisma.user.findUnique({
|
||||||
where: { steamId },
|
where: { steamId },
|
||||||
select: {
|
select: {
|
||||||
steamId: true,
|
steamId: true,
|
||||||
name: true,
|
name: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
|
premierRank: true,
|
||||||
|
faceitUrl: true,
|
||||||
|
faceitNickname: true,
|
||||||
|
faceitGames: {
|
||||||
|
where: { game: 'cs2' },
|
||||||
|
select: { skillLevel: true, elo: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!userRaw) {
|
||||||
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 })
|
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hole alle MatchPlayer-Datensätze inkl. zugehörigem Match
|
const user = {
|
||||||
|
steamId: userRaw.steamId,
|
||||||
|
name: userRaw.name,
|
||||||
|
avatar: userRaw.avatar,
|
||||||
|
premierRank: userRaw.premierRank,
|
||||||
|
faceit: {
|
||||||
|
faceitUrl: userRaw.faceitUrl ?? null,
|
||||||
|
faceitNickname: userRaw.faceitNickname ?? null,
|
||||||
|
faceitGames: (userRaw.faceitGames ?? []).map(g => ({
|
||||||
|
skillLevel: g.skillLevel,
|
||||||
|
elo: g.elo,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle MatchPlayer-Datensätze inkl. zugehörigem Match
|
||||||
const matches = await prisma.matchPlayer.findMany({
|
const matches = await prisma.matchPlayer.findMany({
|
||||||
where: { steamId },
|
where: { steamId },
|
||||||
include: {
|
include: {
|
||||||
match: {
|
match: {
|
||||||
select: {
|
select: {
|
||||||
demoDate: true,
|
demoDate: true,
|
||||||
map: true,
|
// falls du Runden brauchst, hier entsperren:
|
||||||
|
// roundCount: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
stats: true,
|
stats: true,
|
||||||
@ -45,20 +70,27 @@ export async function GET(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Formatiere die Stats wie vom Frontend benötigt
|
// Stats so formatieren, dass MiniPlayerCard sie direkt nutzen kann
|
||||||
const stats = matches.map((entry) => ({
|
const FallbackDate = new Date(0).toISOString().split('T')[0] // "1970-01-01"
|
||||||
date: entry.match?.demoDate?.toISOString().split('T')[0] ?? 'Unbekannt',
|
const stats = matches.map((entry) => {
|
||||||
map: entry.match?.map ?? 'Unbekannt',
|
// Runden – falls dein Stats-Model das Feld nicht hat, ist es einfach null
|
||||||
kills: entry.stats?.kills ?? 0,
|
const rounds =
|
||||||
deaths: entry.stats?.deaths ?? 0,
|
(entry.stats as any)?.rounds ??
|
||||||
assists: entry.stats?.assists ?? 0,
|
// (entry.match as any)?.roundCount ?? // falls du roundCount oben selektierst
|
||||||
totalDamage: entry.stats?.totalDamage ?? 0,
|
null
|
||||||
headshotPct: entry.stats?.headshotPct ?? 0,
|
|
||||||
rankNew: entry.stats?.rankNew ?? null,
|
|
||||||
rankChange: entry.stats?.rankChange ?? null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return NextResponse.json({ user, stats }, { status: 200 })
|
return {
|
||||||
|
date: entry.match?.demoDate?.toISOString().split('T')[0] ?? FallbackDate,
|
||||||
|
kills: entry.stats?.kills ?? 0,
|
||||||
|
deaths: entry.stats?.deaths ?? 0,
|
||||||
|
assists: entry.stats?.assists ?? 0,
|
||||||
|
totalDamage: entry.stats?.totalDamage ?? 0,
|
||||||
|
rounds,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = { user, stats }
|
||||||
|
return NextResponse.json(response, { status: 200 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API/stats] Fehler beim Laden der Daten:', error)
|
console.error('[API/stats] Fehler beim Laden der Daten:', error)
|
||||||
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user