This commit is contained in:
Linrador 2025-10-08 15:04:00 +02:00
parent d7906ad601
commit caaed1f71e
5 changed files with 417 additions and 445 deletions

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
// /src/app/profile/[steamId]/ProfileHeader.tsx
'use client'
import Link from 'next/link'
import { Tabs } from '../../components/Tabs'

View File

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