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'
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
@ -6,14 +7,12 @@ import { useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
import Table from './Table'
|
||||
import PremierRankBadge from './PremierRankBadge'
|
||||
import CompRankBadge from './CompRankBadge'
|
||||
import EditMatchMetaModal from './EditMatchMetaModal'
|
||||
import EditMatchPlayersModal from './EditMatchPlayersModal'
|
||||
import type { EditSide } from './EditMatchPlayersModal'
|
||||
|
||||
import type { Match, MatchPlayer } from '../../../types/match'
|
||||
import Button from './Button'
|
||||
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 }) {
|
||||
const k = m.kills ?? 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)
|
||||
}
|
||||
|
||||
async function buildPlayerSummary(steamId: string): Promise<PlayerSummary | null> {
|
||||
async function buildPlayerSummaryAndFaceit(steamId: string): Promise<{
|
||||
summary: PlayerSummary | null
|
||||
faceit: PrefetchedFaceit | 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
|
||||
if (!res.ok) return { summary: null, faceit: null }
|
||||
|
||||
const data = (await res.json()) as ApiResponse
|
||||
const matches = Array.isArray(data?.stats) ? data.stats : []
|
||||
|
||||
// Summary wie gehabt
|
||||
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)
|
||||
@ -76,19 +102,29 @@ async function buildPlayerSummary(steamId: string): Promise<PlayerSummary | null
|
||||
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 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
|
||||
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
|
||||
const summary: PlayerSummary = { games, kd, avgDmgPerMatch, avgKillsPerMatch, perfDelta, perfSeries }
|
||||
|
||||
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 {
|
||||
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 [userTZ, setUserTZ] = useState<string>('Europe/Berlin')
|
||||
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
|
||||
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 [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)
|
||||
@ -377,6 +413,8 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
const [leadOverride, setLeadOverride] = useState<number | null>(null)
|
||||
const lastHandledKeyRef = useRef<string>('')
|
||||
|
||||
const hoverTimer = useRef<number | null>(null)
|
||||
|
||||
// Rollen & Rechte
|
||||
const me = session?.user
|
||||
const userId = me?.steamId
|
||||
@ -404,76 +442,37 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
const currentMapKey = normalizeMapKey(match.map)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hoverPlayer) return
|
||||
|
||||
const isInCorridor = (x: number, y: number, a: DOMRect, c: DOMRect, pad = 4) => {
|
||||
// gemeinsame vertikale Ausdehnung
|
||||
const top = Math.min(a.top, c.top) - pad
|
||||
const bottom = Math.max(a.bottom, c.bottom) + pad
|
||||
|
||||
// horizontaler Korridor zwischen rechter Ankerkante und linker Kartenkante (oder umgekehrt)
|
||||
const left = Math.min(a.right, c.left) - pad
|
||||
const right = Math.max(a.right, c.left) + pad
|
||||
|
||||
return x >= left && x <= right && y >= top && y <= bottom
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const t = e.target as Node | null
|
||||
const cardEl = cardElRef.current
|
||||
const aEl = anchorEl
|
||||
|
||||
if (!t || !cardEl || !aEl) return
|
||||
|
||||
// 1) Cursor in Card oder Anchor? → offen lassen
|
||||
if (cardEl.contains(t) || aEl.contains(t)) return
|
||||
|
||||
// 2) Im Korridor zwischen beiden?
|
||||
const a = aEl.getBoundingClientRect()
|
||||
const c = cardEl.getBoundingClientRect()
|
||||
if (isInCorridor(e.clientX, e.clientY, a, c, 6)) return
|
||||
|
||||
// 3) sonst schließen
|
||||
setHoverPlayer(null)
|
||||
setHoverRect(null)
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: true })
|
||||
return () => window.removeEventListener('pointermove', onPointerMove)
|
||||
}, [hoverPlayer, anchorEl])
|
||||
|
||||
useEffect(() => {
|
||||
// alle SteamIDs aus beiden Teams
|
||||
const ids = [...(match.teamA?.players ?? []), ...(match.teamB?.players ?? [])]
|
||||
.map(p => p.user?.steamId)
|
||||
.filter((id): id is string => !!id)
|
||||
|
||||
if (ids.length === 0) return
|
||||
if (!ids.length) 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 idsToFetch = ids.filter(id => !(id in playerSummaries) || !(id in playerFaceits))
|
||||
if (!idsToFetch.length) return
|
||||
|
||||
const results = await Promise.all(idsToFetch.map(async (id) => {
|
||||
const summary = await buildPlayerSummary(id)
|
||||
return [id, summary] as const
|
||||
const both = await buildPlayerSummaryAndFaceit(id)
|
||||
return [id, both] as const
|
||||
}))
|
||||
|
||||
if (cancelled) return
|
||||
setPlayerSummaries(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 () => { cancelled = true }
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@ -677,21 +676,18 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
return (
|
||||
<Table.Row key={p.user.steamId} title={title} className={`${banned ? 'bg-red-900/20' : ''}`}>
|
||||
<Table.Cell
|
||||
className={`flex items-center`}
|
||||
className="flex items-center"
|
||||
hoverable
|
||||
onMouseEnter={(e) => {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
setHoverPlayer(p)
|
||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
||||
setAnchorEl(e.currentTarget as HTMLElement)
|
||||
setAnchorEl(el)
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (hoverPlayer?.user.steamId === p.user.steamId) {
|
||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
||||
}
|
||||
onMouseLeave={() => {
|
||||
if (hoverTimer.current) { window.clearTimeout(hoverTimer.current); hoverTimer.current = null }
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
setHoverPlayer(p)
|
||||
setHoverRect(e.currentTarget.getBoundingClientRect())
|
||||
setAnchorEl(e.currentTarget as HTMLElement)
|
||||
}}
|
||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||
@ -966,12 +962,12 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
|
||||
{/* Teams / Tabellen */}
|
||||
{/* Team A */}
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{renderTable(teamAPlayers, teamATitle, showEditA, () => setEditSide('A'))}
|
||||
</div>
|
||||
|
||||
{/* Team B */}
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{renderTable(teamBPlayers, teamBTitle, showEditB, () => setEditSide('B'))}
|
||||
</div>
|
||||
|
||||
@ -1018,9 +1014,17 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
<MiniPlayerCard
|
||||
open={!!hoverPlayer}
|
||||
player={hoverPlayer}
|
||||
anchor={hoverRect}
|
||||
onClose={() => { setHoverPlayer(null); setHoverRect(null); setAnchorEl(null) }}
|
||||
anchor={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}
|
||||
prefetchedFaceit={hoverPlayer.user?.steamId ? playerFaceits[hoverPlayer.user.steamId] ?? null : null}
|
||||
anchorEl={anchorEl}
|
||||
onCardMount={(el) => { cardElRef.current = el }}
|
||||
/>
|
||||
|
||||
@ -4,10 +4,11 @@
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { MatchPlayer } from '../../../types/match'
|
||||
import PremierRankBadge from './PremierRankBadge'
|
||||
import CompRankBadge from './CompRankBadge'
|
||||
import FaceitLevelImage from './FaceitLevelBadge'
|
||||
|
||||
export type MiniPlayerCardProps = {
|
||||
open: boolean
|
||||
@ -15,42 +16,35 @@ export type MiniPlayerCardProps = {
|
||||
anchor: DOMRect | null
|
||||
onClose?: () => void
|
||||
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
|
||||
/** 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
|
||||
}
|
||||
|
||||
type FaceitState = {
|
||||
level: number | null
|
||||
elo: number | null
|
||||
nickname: string | null
|
||||
url: string | null
|
||||
}
|
||||
|
||||
/** gleiche Struktur wie in MatchDetails */
|
||||
export type PlayerSummary = {
|
||||
games: number
|
||||
@ -61,39 +55,8 @@ export type PlayerSummary = {
|
||||
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 W = 200, H = 40, pad = 6, 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(' ')
|
||||
@ -105,319 +68,265 @@ function Sparkline({ values }: { values: number[] }) {
|
||||
}
|
||||
|
||||
export default function MiniPlayerCard({
|
||||
open, player, anchor, onClose, prefetchedSummary, anchorEl, onCardMount
|
||||
open, player, anchor, onClose, prefetchedSummary, prefetchedFaceit, anchorEl, onCardMount
|
||||
}: MiniPlayerCardProps) {
|
||||
const router = useRouter()
|
||||
|
||||
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
|
||||
const [pos, setPos] = useState<{ top: number; left: number; side: 'right' | 'left' }>({
|
||||
top: 0, left: 0, side: 'right'
|
||||
})
|
||||
// Hover-Intent
|
||||
const openT = useRef<number | null>(null)
|
||||
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 steam64 = u.steamId ?? null
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Summary nur aus Prefetch (kein Fetch)
|
||||
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)
|
||||
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)
|
||||
// FACEIT aus Prefetch / user-Fallback
|
||||
const faceit = useMemo<FaceitState>(() => {
|
||||
const url =
|
||||
prefetchedFaceit?.url
|
||||
?? (u.faceitUrl ? u.faceitUrl.replace('{lang}', 'en')
|
||||
: (u.faceitNickname ? `https://www.faceit.com/en/players/${encodeURIComponent(u.faceitNickname)}` : 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?.()
|
||||
return {
|
||||
level: prefetchedFaceit?.level ?? u.faceitLevel ?? null,
|
||||
elo: prefetchedFaceit?.elo ?? u.faceitElo ?? null,
|
||||
nickname: prefetchedFaceit?.nickname ?? u.faceitNickname ?? null,
|
||||
url,
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
}
|
||||
}, [open, onClose])
|
||||
}, [prefetchedFaceit, u.faceitUrl, u.faceitNickname, u.faceitLevel, u.faceitElo])
|
||||
|
||||
const setCardRef = (el: HTMLDivElement | null) => {
|
||||
const setRef = (el: HTMLDivElement | null) => {
|
||||
cardRef.current = el
|
||||
onCardMount?.(el) // Parent informieren
|
||||
if (open) schedulePosition()
|
||||
onCardMount?.(el)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Positionierung
|
||||
const doPosition = () => {
|
||||
if (!cardRef.current || !anchorEl) return
|
||||
const a = anchorEl.getBoundingClientRect()
|
||||
const cardEl = cardRef.current
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
const prevVis = cardEl.style.visibility
|
||||
cardEl.style.visibility = 'hidden'
|
||||
const vw = window.innerWidth, vh = window.innerHeight
|
||||
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 rightLeft = a.right
|
||||
const leftLeft = a.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)
|
||||
const topRaw = a.top + (a.height - ch) / 2
|
||||
const top = Math.max(8, Math.min(topRaw, vh - ch - 8))
|
||||
|
||||
setPos({ top: Math.round(top), left: Math.round(left), side })
|
||||
cardEl.style.visibility = prevVis
|
||||
setMeasured(true)
|
||||
}
|
||||
|
||||
// ----- 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 schedule = () => requestAnimationFrame(() => requestAnimationFrame(doPosition))
|
||||
|
||||
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 isBannedNested =
|
||||
!!(nestedBan?.vacBanned || (nestedBan?.numberOfVACBans ?? 0) > 0 ||
|
||||
(nestedBan?.numberOfGameBans ?? 0) > 0 || nestedBan?.communityBanned ||
|
||||
(nestedBan?.economyBan && nestedBan.economyBan !== 'none'))
|
||||
|
||||
(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}`)
|
||||
const src = nestedBan ?? flat
|
||||
if (src.vacBanned) parts.push('VAC-Ban aktiv')
|
||||
if ((src.numberOfVACBans ?? 0) > 0) parts.push(`VAC-Bans: ${src.numberOfVACBans}`)
|
||||
if ((src.numberOfGameBans ?? 0) > 0) parts.push(`Game-Bans: ${src.numberOfGameBans}`)
|
||||
if (src.communityBanned) parts.push('Community-Ban')
|
||||
if (src.economyBan && src.economyBan !== 'none') parts.push(`Economy: ${src.economyBan}`)
|
||||
if (typeof src.daysSinceLastBan === 'number') parts.push(`Tage seit letztem Ban: ${src.daysSinceLastBan}`)
|
||||
return parts.join(' · ')
|
||||
}, [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
|
||||
|
||||
// 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}
|
||||
ref={setRef}
|
||||
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
|
||||
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, opacity: measured ? 1 : 0 }}
|
||||
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 */}
|
||||
<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'
|
||||
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">
|
||||
{/* Header mit Links rechts */}
|
||||
<div
|
||||
onClick={() => { steam64 ? router.push(`/profile/${steam64}`) : null }}
|
||||
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"
|
||||
>
|
||||
{/* Links: Avatar + Name + Badges */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* 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 */}
|
||||
<div className="truncate text-sm font-semibold">
|
||||
{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>
|
||||
</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>
|
||||
)}
|
||||
{faceit.url && (
|
||||
<Link
|
||||
href={faceit.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="FACEIT-Profil öffnen"
|
||||
title={`Faceit-Profil${faceit.nickname ? ` von ${faceit.nickname}` : ''}`}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-3 h-px bg-white/10" />
|
||||
|
||||
{/* Mini-Profil */}
|
||||
{/* Mini-Profil / Stats */}
|
||||
<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">
|
||||
@ -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="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(' ')}>
|
||||
<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>
|
||||
@ -447,7 +358,8 @@ export default function MiniPlayerCard({
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(body, document.body)
|
||||
const target = document.getElementById('__next') || document.body
|
||||
return createPortal(body, target)
|
||||
}
|
||||
|
||||
/* --- kleine UI-Bausteine --- */
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// Tabs.tsx
|
||||
// /src/app/[locale]/components/Tabs.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
@ -15,13 +16,19 @@ type TabsProps = {
|
||||
/** optional kontrollierter Modus */
|
||||
value?: string
|
||||
onChange?: (name: string) => void
|
||||
/** neu: Ausrichtung */
|
||||
/** Ausrichtung */
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
/** optional: Styling */
|
||||
className?: string
|
||||
tabClassName?: string
|
||||
}
|
||||
|
||||
function normalize(path: string) {
|
||||
if (!path) return '/'
|
||||
const v = path.replace(/\/+$/, '')
|
||||
return v === '' ? '/' : v
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
children,
|
||||
value,
|
||||
@ -31,8 +38,23 @@ export function Tabs({
|
||||
tabClassName = ''
|
||||
}: TabsProps) {
|
||||
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 current = normalize(pathname)
|
||||
|
||||
// Liste aller Tab-URLs (normalisiert) für die Heuristik
|
||||
const hrefs = tabs.map(t => normalize(t.props.href))
|
||||
|
||||
return (
|
||||
<nav
|
||||
@ -45,50 +67,18 @@ export function Tabs({
|
||||
role="tablist"
|
||||
aria-orientation={isVertical ? 'vertical' : 'horizontal'}
|
||||
>
|
||||
{tabs
|
||||
.filter(
|
||||
(tab): tab is ReactElement<TabProps> =>
|
||||
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 + '/')
|
||||
{tabs.map((tab, index) => {
|
||||
const baseClasses =
|
||||
'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
|
||||
|
||||
// Kontrollierter Modus: Auswahl über value/onChange
|
||||
if (onChange && value !== undefined) {
|
||||
const isActive = value === tab.props.name
|
||||
return (
|
||||
<Link
|
||||
<button
|
||||
key={index}
|
||||
href={tab.props.href}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.props.name)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={
|
||||
@ -100,11 +90,44 @@ export function Tabs({
|
||||
}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { Tabs } from '../../components/Tabs'
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// /src/app/api/stats/[steamId]/route.ts
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
@ -12,28 +13,52 @@ export async function GET(
|
||||
}
|
||||
|
||||
try {
|
||||
// Hole den User (nur die nötigsten Felder)
|
||||
const user = await prisma.user.findUnique({
|
||||
// User (nur nötige Felder) – Faceit-Daten werden in ein verschachteltes Objekt gemappt
|
||||
const userRaw = await prisma.user.findUnique({
|
||||
where: { steamId },
|
||||
select: {
|
||||
steamId: true,
|
||||
name: 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 })
|
||||
}
|
||||
|
||||
// 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({
|
||||
where: { steamId },
|
||||
include: {
|
||||
match: {
|
||||
select: {
|
||||
demoDate: true,
|
||||
map: true,
|
||||
// falls du Runden brauchst, hier entsperren:
|
||||
// roundCount: true,
|
||||
},
|
||||
},
|
||||
stats: true,
|
||||
@ -45,20 +70,27 @@ export async function GET(
|
||||
},
|
||||
})
|
||||
|
||||
// Formatiere die Stats wie vom Frontend benötigt
|
||||
const stats = matches.map((entry) => ({
|
||||
date: entry.match?.demoDate?.toISOString().split('T')[0] ?? 'Unbekannt',
|
||||
map: entry.match?.map ?? 'Unbekannt',
|
||||
kills: entry.stats?.kills ?? 0,
|
||||
deaths: entry.stats?.deaths ?? 0,
|
||||
assists: entry.stats?.assists ?? 0,
|
||||
totalDamage: entry.stats?.totalDamage ?? 0,
|
||||
headshotPct: entry.stats?.headshotPct ?? 0,
|
||||
rankNew: entry.stats?.rankNew ?? null,
|
||||
rankChange: entry.stats?.rankChange ?? null,
|
||||
}))
|
||||
// Stats so formatieren, dass MiniPlayerCard sie direkt nutzen kann
|
||||
const FallbackDate = new Date(0).toISOString().split('T')[0] // "1970-01-01"
|
||||
const stats = matches.map((entry) => {
|
||||
// Runden – falls dein Stats-Model das Feld nicht hat, ist es einfach null
|
||||
const rounds =
|
||||
(entry.stats as any)?.rounds ??
|
||||
// (entry.match as any)?.roundCount ?? // falls du roundCount oben selektierst
|
||||
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) {
|
||||
console.error('[API/stats] Fehler beim Laden der Daten:', error)
|
||||
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user