This commit is contained in:
Linrador 2025-10-01 17:02:21 +02:00
parent bd365390d5
commit bacf848455
8 changed files with 717 additions and 512 deletions

37
package-lock.json generated
View File

@ -85,7 +85,6 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -123,6 +122,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -1577,7 +1577,6 @@
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@ -2068,6 +2067,7 @@
"integrity": "sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.19.2"
}
@ -2085,6 +2085,7 @@
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -2182,6 +2183,7 @@
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.30.1",
"@typescript-eslint/types": "8.30.1",
@ -2615,6 +2617,7 @@
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3283,7 +3286,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@ -3424,6 +3426,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@ -3880,6 +3883,7 @@
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -4054,6 +4058,7 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@ -5402,7 +5407,6 @@
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
@ -5831,7 +5835,6 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"peer": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -6019,6 +6022,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.3.0.tgz",
"integrity": "sha512-k0MgP6BsK8cZ73wRjMazl2y2UcXj49ZXLDEgx6BikWuby/CN+nh81qFFI16edgd7xYpe/jj2OZEIwCoqnzz0bQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.3.0",
"@swc/counter": "0.1.3",
@ -6311,8 +6315,7 @@
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
@ -6329,7 +6332,6 @@
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 6"
}
@ -6458,7 +6460,6 @@
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
@ -6745,7 +6746,6 @@
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pretty-format": "^3.8.0"
},
@ -6776,8 +6776,7 @@
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/prisma": {
"version": "6.16.3",
@ -6786,6 +6785,7 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.16.3",
"@prisma/engines": "6.16.3"
@ -6902,6 +6902,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6911,6 +6912,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -6980,8 +6982,7 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@ -7638,7 +7639,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.2.1",
@ -7695,6 +7697,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7920,6 +7923,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8175,8 +8179,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/yn": {
"version": "3.1.1",

View File

@ -28,6 +28,9 @@ import {
Title,
Filler,
RadarController,
DoughnutController,
BarController,
LineController
} from 'chart.js';
ChartJS.register(
@ -39,6 +42,9 @@ ChartJS.register(
LineElement,
ArcElement,
RadarController,
DoughnutController,
BarController,
LineController,
Tooltip,
Legend,
Title,

View File

@ -1,4 +1,3 @@
// /src/app/profile/[steamId]/Profile.tsx
import Link from 'next/link'
import Card from '../../Card'
@ -12,23 +11,23 @@ type ApiStats = {
assists?: number | null
totalDamage?: number | null
matchType?: string | null
rounds?: number | null
}>
}
/* ───────── helpers ───────── */
const fmtInt = (n: number) => new Intl.NumberFormat('de-DE').format(n)
const nf = new Intl.NumberFormat('de-DE')
const fmtInt = (n: number) => nf.format(Math.round(n))
const fmtDateTime = (iso: string) =>
new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso))
const kdRaw = (k: number, d: number) => (d <= 0 ? Infinity : k / d)
const kdLabel = (k: number, d: number) => (d <= 0 ? '∞' : (k / d).toFixed(2))
const kdTint = (k: number, d: number) => {
const v = kdRaw(k, d)
if (v === Infinity || v >= 1.3) return 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
if (v >= 1.0) return 'text-blue-400 bg-blue-500/10 ring-blue-500/20'
if (v >= 0.8) return 'text-amber-400 bg-amber-500/10 ring-amber-500/20'
return 'text-rose-400 bg-rose-500/10 ring-rose-500/20'
}
const kdRaw = (k: number, d: number) => (d <= 0 ? 2 : k / Math.max(1, d)) // clamp nach oben später
const kdTxt = (k: number, d: number) => (d <= 0 ? '∞' : (k / Math.max(1, d)).toFixed(2))
const kdTone = (v: number) =>
v >= 1.4 ? 'text-emerald-300 bg-emerald-500/10 ring-emerald-400/20' :
v >= 1.05 ? 'text-blue-300 bg-blue-500/10 ring-blue-400/20' :
v >= 0.9 ? 'text-amber-300 bg-amber-500/10 ring-amber-400/20' :
'text-rose-300 bg-rose-500/10 ring-rose-400/20'
async function getStats(steamId: string): Promise<ApiStats | null> {
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
@ -38,31 +37,22 @@ async function getStats(steamId: string): Promise<ApiStats | null> {
}
/* ───────── UI atoms ───────── */
function Pill({
label,
value,
icon,
className = '',
}: {
label: string
value: string
icon?: React.ReactNode
className?: string
}) {
function Chip({
label, value, icon, className = '',
}: { label: string; value: string; icon?: React.ReactNode; className?: string }) {
return (
<div
className={[
'flex items-center justify-between rounded-xl border border-white/5 bg-neutral-900/40',
'px-4 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,.04)] backdrop-blur',
className,
].join(' ')}
>
<div className={[
'rounded-xl px-4 py-3',
'bg-white/[0.04] ring-1 ring-inset ring-white/10 backdrop-blur',
'flex items-center justify-between gap-3 shadow-[inset_0_1px_0_rgba(255,255,255,.04)]',
className,
].join(' ')}>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-white/60">{label}</div>
<div className="mt-0.5 text-2xl font-semibold text-white">{value}</div>
<div className="text-[11px] uppercase tracking-wide text-white/55">{label}</div>
<div className="mt-0.5 text-xl font-semibold text-white">{value}</div>
</div>
{icon && (
<div className="ml-3 grid h-10 w-10 place-items-center rounded-lg ring-1 ring-inset ring-white/10 bg-white/5 text-white/80">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-white/5 ring-1 ring-inset ring-white/10 text-white/80">
{icon}
</div>
)}
@ -70,62 +60,74 @@ function Pill({
)
}
function CtaCard({
title,
desc,
href,
tone = 'blue',
}: {
title: string
desc: string
href: string
tone?: 'blue' | 'neutral'
function CtaCard({ title, desc, href, tone = 'blue' }:{
title: string; desc: string; href: string; tone?: 'blue'|'neutral'
}) {
const button =
tone === 'blue'
? 'bg-blue-600 hover:bg-blue-700 ring-blue-500/30'
: 'bg-neutral-700 hover:bg-neutral-600 ring-white/10'
const btn = tone === 'blue'
? 'bg-blue-600 hover:bg-blue-700 ring-blue-500/30'
: 'bg-neutral-700 hover:bg-neutral-600 ring-white/10'
return (
<Card>
<div className="rounded-2xl bg-white/[0.035] ring-1 ring-inset ring-white/10 p-5 backdrop-blur">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-white">{title}</h3>
<h3 className="text-base font-semibold text-white">{title}</h3>
<p className="mt-1 text-sm text-white/60">{desc}</p>
</div>
<Link
href={href}
className={[
'shrink-0 rounded-lg px-3 py-1.5 text-sm font-medium text-white ring-1 ring-inset transition',
button,
].join(' ')}
className={['px-3 py-1.5 rounded-lg text-sm font-medium text-white ring-1 ring-inset transition', btn].join(' ')}
>
Öffnen
</Link>
</div>
</Card>
</div>
)
}
/** sehr kleine Inline-Sparkline (ohne Lib) */
function Sparkline({ values }: { values: number[] }) {
const W = 160
const H = 40
const pad = 4
const n = Math.max(1, values.length)
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const range = Math.max(0.05, max - min)
const step = (W - pad * 2) / Math.max(1, n - 1)
const pts = values
.map((v, i) => {
const x = pad + i * step
const y = H - pad - ((v - min) / range) * (H - pad * 2)
return `${x},${y}`
})
.join(' ')
/** einfacher Donut-Gauge für K/D rein als SVG */
function KdGauge({ value }:{ value: number }) {
// Normiere K/D in Bereich 0..2 (2 fühlt sich als „voll“ gut an)
const max = 2
const v = Math.max(0, Math.min(max, value))
const pct = v / max
const r = 38
const c = 2 * Math.PI * r
const dash = c * pct
const gap = c - dash
const tone =
value >= 1.4 ? '#34d399' : // emerald
value >= 1.05 ? '#60a5fa' : // blue
value >= 0.9 ? '#fbbf24' : // amber
'#fb7185' // rose
return (
<svg viewBox={`0 0 ${W} ${H}`} className="h-10 w-40">
<polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.9" strokeWidth="2" />
<svg viewBox="0 0 100 100" className="h-24 w-24">
{/* Hintergrund */}
<circle cx="50" cy="50" r={r} fill="none" stroke="rgba(255,255,255,.12)" strokeWidth="8"/>
{/* Fortschritt */}
<g transform="rotate(-90 50 50)">
<circle cx="50" cy="50" r={r} fill="none" stroke={tone} strokeWidth="8"
strokeLinecap="round" strokeDasharray={`${dash} ${gap}`} />
</g>
{/* Text */}
<text x="50" y="48" textAnchor="middle" fontSize="16" fontWeight="700" fill="white">{value === Infinity ? '∞' : value.toFixed(2)}</text>
<text x="50" y="64" textAnchor="middle" fontSize="9" fill="rgba(255,255,255,.65)">K/D</text>
</svg>
)
}
/** kleine Sparkline (Trend) */
function Sparkline({ values }: { values: number[] }) {
const W = 240, H = 56, pad = 6
const n = Math.max(1, values.length)
const max = Math.max(...values, 1), min = Math.min(...values, 0), range = Math.max(0.05, max - min)
const step = (W - pad * 2) / Math.max(1, n - 1)
const pts = values.map((v, i) => `${pad + i*step},${H - pad - ((v - min) / range) * (H - pad*2)}`).join(' ')
return (
<svg viewBox={`0 0 ${W} ${H}`} className="w-[240px] h-[56px]">
<polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.95" strokeWidth="2"/>
</svg>
)
}
@ -135,135 +137,119 @@ export default async function Profile({ steamId }: Props) {
const data = await getStats(steamId)
const matches = 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 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 assists = matches.reduce((s, m) => s + (m.assists ?? 0), 0)
const dmg = matches.reduce((s, m) => s + (m.totalDamage ?? 0), 0)
const kdTxt = kdLabel(kills, deaths)
const damage = matches.reduce((s, m) => s + (m.totalDamage ?? 0), 0)
// letzten 10 für Sparkline (K/D)
const last10 = matches.slice(0, 10).reverse()
const kdSeries = last10.map((m) => (m.deaths > 0 ? (m.kills ?? 0) / m.deaths : 2)) // 2 als „gut“ bei 0 Toden
const lastKD = kdSeries.length ? kdSeries[kdSeries.length - 1] : 0
const prevKD =
kdSeries.length > 1 ? kdSeries.slice(0, -1).reduce((a, b) => a + b, 0) / (kdSeries.length - 1) : lastKD
const deltaKD = lastKD - prevKD
const kdVal = kdRaw(kills, deaths)
const kdClass = kdTone(kdVal)
const recent = matches.slice(0, 5)
// Trend (letzte 10 K/D)
const last10 = matches.slice(0, 10).reverse()
const kdSeries = last10.map(m => (m.deaths > 0 ? (m.kills ?? 0) / m.deaths : 2))
const lastKD = kdSeries.at(-1) ?? kdVal
const prevKD = kdSeries.length > 1 ? kdSeries.slice(0,-1).reduce((a,b)=>a+b,0) / (kdSeries.length-1) : kdVal
const deltaKD = lastKD - prevKD
// Highlights (aus verfügbaren Daten, keine neue API)
const avgDmgPerMatch = games ? damage / games : 0
const avgAstPerMatch = games ? assists / games : 0
const recent = matches.slice(0, 6)
return (
<div className="space-y-7">
{/* Performance Panel */}
<Card>
<div className="grid items-center gap-4 md:grid-cols-[1fr_auto_auto]">
<div>
<div className="text-[11px] uppercase tracking-wide text-white/60">Aktuelle Form</div>
<div className="mt-1 flex items-center gap-2">
<div
className={[
'rounded-md px-2 py-1 text-sm font-semibold ring-1 ring-inset',
kdTint(kills, deaths),
].join(' ')}
title="Gesamt K/D"
>
Ø K/D&nbsp;&nbsp;{kdTxt}
</div>
<div
className={[
'rounded-md px-2 py-1 text-xs ring-1 ring-inset',
deltaKD >= 0
? 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
: 'text-rose-400 bg-rose-500/10 ring-rose-500/20',
].join(' ')}
title="Veränderung zur vorherigen Form"
>
<div className="space-y-8">
{/* ——— Hero-Banner mit Grid/Noise ——— */}
<div className="relative overflow-hidden rounded-2xl ring-1 ring-inset ring-white/10">
<div className="absolute inset-0 bg-[radial-gradient(1200px_400px_at_20%_-20%,rgba(59,130,246,.25),transparent),radial-gradient(900px_300px_at_90%_0%,rgba(16,185,129,.20),transparent)]" />
<div className="absolute inset-0 [background:linear-gradient(180deg,rgba(255,255,255,.06),transparent_20%),repeating-linear-gradient(0deg,transparent,transparent_9px,rgba(255,255,255,.03)_10px)]" />
<div className="absolute inset-0 opacity-[0.15] mix-blend-overlay" style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 60 60%27%3E%3Cfilter id=%27n%27%3E%3CfeTurbulence baseFrequency=%270.8%27 numOctaves=%275%27 stitchTiles=%27stitch%27/%3E%3C/filter%3E%3Crect width=%2760%27 height=%2760%27 filter=%27url(%23n)%27/%3E%3C/svg%3E")' }} />
<div className="relative p-5 md:p-6 grid gap-6 md:grid-cols-[auto_1fr_auto] items-center">
{/* Donut */}
<div className="justify-self-center md:justify-self-start">
<KdGauge value={kdVal}/>
</div>
{/* Texte + Sparkline */}
<div className="min-w-0">
<div className="flex items-center gap-3">
<div className={['rounded-md px-2 py-1 text-sm font-semibold ring-1 ring-inset', kdClass].join(' ')}>Ø K/D {kdTxt(kills,deaths)}</div>
<div className={[
'rounded-md px-2 py-1 text-xs ring-1 ring-inset',
deltaKD >= 0 ? 'text-emerald-300 bg-emerald-500/10 ring-emerald-400/20' : 'text-rose-300 bg-rose-500/10 ring-rose-400/20',
].join(' ')}>
{deltaKD >= 0 ? '▲' : '▼'} {Math.abs(deltaKD).toFixed(2)}
</div>
</div>
<div className="mt-2 text-blue-400">
<Sparkline values={kdSeries}/>
</div>
</div>
<div className="justify-self-center text-blue-400">
<Sparkline values={kdSeries} />
</div>
<div className="justify-self-end grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg bg-white/5 px-3 py-2 ring-1 ring-white/10">
<div className="text-[11px] uppercase tracking-wide text-white/60">Assists</div>
{/* Two mini stats */}
<div className="grid grid-cols-2 gap-2 text-sm justify-self-end">
<div className="rounded-lg bg-white/6 px-3 py-2 ring-1 ring-inset ring-white/10">
<div className="text-[11px] uppercase tracking-wide text-white/65">Assists</div>
<div className="mt-0.5 font-semibold text-white">{fmtInt(assists)}</div>
</div>
<div className="rounded-lg bg-white/5 px-3 py-2 ring-1 ring-white/10">
<div className="text-[11px] uppercase tracking-wide text-white/60">Damage</div>
<div className="mt-0.5 font-semibold text-white">{fmtInt(Math.round(dmg))}</div>
<div className="rounded-lg bg-white/6 px-3 py-2 ring-1 ring-inset ring-white/10">
<div className="text-[11px] uppercase tracking-wide text-white/65">Damage</div>
<div className="mt-0.5 font-semibold text-white">{fmtInt(damage)}</div>
</div>
</div>
</div>
</Card>
{/* KPIs */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Pill
label="Matches"
value={fmtInt(games)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
}
/>
<Pill
label="Kills"
value={fmtInt(kills)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M12 19V5M5 12h14" />
</svg>
}
/>
<Pill
label="K/D"
value={kdTxt}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 3v18M21 21H9" />
</svg>
}
/>
<Pill
label="Damage (Summe)"
value={fmtInt(Math.round(dmg))}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M6 20 18 4M14 20h6v-6" />
</svg>
}
/>
</div>
{/* CTAs */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CtaCard
title="Statistiken"
desc="Charts, Verläufe und Map-Auswertungen."
href={`/profile/${steamId}/stats`}
tone="blue"
/>
<CtaCard
title="Matches"
desc="Alle Spiele in einer übersichtlichen Liste."
href={`/profile/${steamId}/matches`}
tone="neutral"
/>
{/* ——— kompakte KPI-Chips ——— */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Chip label="Matches" value={fmtInt(games)} icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
} />
<Chip label="Kills" value={fmtInt(kills)} icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M12 19V5M5 12h14" />
</svg>
} />
<Chip label="K/D" value={kdTxt(kills,deaths)} icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 3v18M21 21H9" />
</svg>
} />
<Chip label="Damage (Summe)" value={fmtInt(damage)} icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M6 20 18 4M14 20h6v-6" />
</svg>
} />
</div>
{/* Letzte Matches */}
{/* ——— Highlights + CTAs ——— */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="rounded-2xl bg-white/[0.035] ring-1 ring-inset ring-white/10 p-5 backdrop-blur">
<div className="text-[11px] uppercase tracking-wide text-white/55">Ø Damage / Match</div>
<div className="mt-1 text-2xl font-semibold text-white">{fmtInt(avgDmgPerMatch)}</div>
<p className="mt-1 text-sm text-white/60">Durchschnittlicher Gesamtschaden pro Spiel.</p>
</div>
<div className="rounded-2xl bg-white/[0.035] ring-1 ring-inset ring-white/10 p-5 backdrop-blur">
<div className="text-[11px] uppercase tracking-wide text-white/55">Ø Assists / Match</div>
<div className="mt-1 text-2xl font-semibold text-white">{fmtInt(avgAstPerMatch)}</div>
<p className="mt-1 text-sm text-white/60">Hilfreiche Aktionen im Team konstant wichtig.</p>
</div>
<div className="grid gap-4">
<CtaCard title="Statistiken" desc="Charts, Verläufe und Map-Auswertungen." href={`/profile/${steamId}/stats`} tone="blue" />
<CtaCard title="Matches" desc="Alle Spiele in einer übersichtlichen Liste." href={`/profile/${steamId}/matches`} tone="neutral" />
</div>
</div>
{/* ——— Timeline: letzte Matches ——— */}
<Card>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-white">Letzte Matches</h3>
<Link
href={`/profile/${steamId}/matches`}
className="text-sm font-medium text-blue-400 hover:text-blue-300 hover:underline"
>
<Link href={`/profile/${steamId}/matches`} className="text-sm font-medium text-blue-400 hover:text-blue-300 hover:underline">
Alle ansehen
</Link>
</div>
@ -273,42 +259,39 @@ export default async function Profile({ steamId }: Props) {
<p className="text-sm text-white/60">Noch keine Daten.</p>
</div>
) : (
<ul className="divide-y divide-white/5">
<ul className="relative">
{/* vertikale Linie */}
<span aria-hidden className="absolute left-3 top-0 bottom-0 w-px bg-white/10" />
{recent.map((m, i) => {
const kdTxtRow = kdLabel(m.kills ?? 0, m.deaths ?? 0)
const kdCls = kdTint(m.kills ?? 0, m.deaths ?? 0)
const kd = kdRaw(m.kills ?? 0, m.deaths ?? 0)
const tone = kdTone(kd)
return (
<li key={i} className="py-3.5">
<li key={i} className="relative pl-10 py-3 border-b border-white/5 last:border-0">
{/* Bullet */}
<span aria-hidden className="absolute left-2 top-5 -translate-x-1/2 size-2.5 rounded-full bg-white/30 ring-2 ring-white/60" />
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs text-white/50">{fmtDateTime(m.date)}</div>
<div className="text-xs text-white/55">{fmtDateTime(m.date)}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-sm">
{m.matchType && (
<span className="rounded-md bg-white/5 px-2 py-0.5 text-[12px] font-medium text-white/80 ring-1 ring-inset ring-white/10">
{m.matchType}
</span>
)}
<span className="text-white">
<span className="text-white/70">K</span> {m.kills}&nbsp;&nbsp;
<span className="text-white/70">D</span> {m.deaths}
{Number.isFinite(m.assists) ? (
<span className="text-white/90">
<span className="text-white/60">K</span> {m.kills}
&nbsp;&nbsp;<span className="text-white/60">D</span> {m.deaths}
{Number.isFinite(m.assists) && (
<>
&nbsp;&nbsp;<span className="text-white/70">A</span> {m.assists}
&nbsp;&nbsp;<span className="text-white/60">A</span> {m.assists}
</>
) : null}
)}
</span>
</div>
</div>
<div className="shrink-0">
<span
className={[
'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset',
kdCls,
].join(' ')}
title="Kill/Death Ratio"
>
K/D&nbsp;{kdTxtRow}
<span className={['inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset', tone].join(' ')}>
K/D&nbsp;{kd === 2 && (m.deaths ?? 0) === 0 ? '∞' : kd.toFixed(2)}
</span>
</div>
</div>

View File

@ -7,372 +7,438 @@ import Card from '../../../Card'
import { MatchStats } from '@/types/match'
type Props = {
steamId: string // 👈 neu: Profil-ID
steamId: string
stats: { matches: MatchStats[] }
}
// ── helpers ──────────────────────────────────────────────────────────────
/* ─ helpers ─ */
const fmtInt = (n: number) => new Intl.NumberFormat('de-DE').format(n)
const fmtDate = (iso: string) =>
new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }).format(
new Date(iso),
)
const fmtShortDate = (iso: string) =>
new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' }).format(new Date(iso))
const kd = (k = 0, d = 0) => (d <= 0 ? Infinity : k / d)
const kdLabel = (k = 0, d = 0) => (d <= 0 ? '∞' : (k / d).toFixed(2))
const tintForKD = (v: number) =>
v === Infinity || v >= 1.3
? 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
: v >= 1.0
? 'text-blue-400 bg-blue-500/10 ring-blue-500/20'
: v >= 0.8
? 'text-amber-400 bg-amber-500/10 ring-amber-500/20'
: 'text-rose-400 bg-rose-500/10 ring-rose-500/20'
const normMapKey = (raw?: string) =>
(raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
const humanizeMap = (key: string) =>
key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
const kd = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number' ? (d === 0 ? Infinity : k / d) : NaN
const kdLabel = (k?: number, d?: number) => {
const v = kd(k, d)
if (!Number.isFinite(v)) return '∞'
return v.toFixed(2)
}
const tintForKD = (value: number) => {
if (!Number.isFinite(value)) return 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
if (value >= 1.3) return 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
if (value >= 1.0) return 'text-blue-400 bg-blue-500/10 ring-blue-500/20'
if (value >= 0.8) return 'text-amber-400 bg-amber-500/10 ring-amber-500/20'
return 'text-rose-400 bg-rose-500/10 ring-rose-500/20'
}
// einheitliche Farbtöne (passen zu Dark UI)
const tone = {
blue: 'rgba(54, 162, 235, 0.6)',
blueBg: 'rgba(54, 162, 235, 0.15)',
red: 'rgba(255, 99, 132, 0.6)',
redBg: 'rgba(255, 99, 132, 0.2)',
amber: 'rgba(255, 206, 86, 0.6)',
amberBg: 'rgba(255, 206, 86, 0.2)',
violet: 'rgba(153, 102, 255, 0.6)',
violetBg: 'rgba(153, 102, 255, 0.2)',
teal: 'rgba(75, 192, 192, 0.6)',
tealBg: 'rgba(75, 192, 192, 0.2)',
orange: 'rgba(255, 159, 64, 0.6)',
orangeBg: 'rgba(255, 159, 64, 0.2)',
blue: 'rgba(54,162,235,.9)',
blueBg: 'rgba(54,162,235,.16)',
red: 'rgba(239,68,68,.95)',
redBg: 'rgba(239,68,68,.15)',
amber: 'rgba(251,191,36,.95)',
amberBg: 'rgba(251,191,36,.16)',
violet: 'rgba(139,92,246,.95)',
violetBg: 'rgba(139,92,246,.16)',
teal: 'rgba(45,212,191,.95)',
tealBg: 'rgba(45,212,191,.16)',
orange: 'rgba(255,159,64,.95)',
orangeBg: 'rgba(255,159,64,.16)',
}
function Pill({
/** formatiert ADR mit 1 Nachkommastelle */
const fmtADR = (v: number) =>
new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v)
/** robust: hole Rundenzahl aus MatchStats */
function getRounds(m: Partial<MatchStats>): number {
// passe diese Reihenfolge ggf. an deine Felder an
return (
(m as any).rounds ??
(m as any).roundCount ??
(m as any).roundsPlayed ??
(m as any).roundsTotal ??
0
) || 0
}
/* kleine Sparkline ohne Lib */
function Sparkline({ values }: { values: number[] }) {
const W = 180, H = 42, pad = 4
const n = Math.max(1, values.length)
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const range = Math.max(0.05, max - min)
const step = (W - pad * 2) / Math.max(1, n - 1)
const pts = values.map((v, i) => `${pad + i * step},${H - pad - ((v - min) / range) * (H - pad * 2)}`).join(' ')
return (
<svg viewBox={`0 0 ${W} ${H}`} className="h-10 w-[180px] text-blue-400">
<polyline points={pts} fill="none" stroke="currentColor" strokeOpacity="0.9" strokeWidth="2" />
</svg>
)
}
/* kleine UI-Bausteine */
function Metric({
label,
value,
hint,
icon,
className = '',
}: {
label: string
value: string
icon?: React.ReactNode
className?: string
}) {
}: { label: string; value: string; hint?: string; icon?: React.ReactNode; className?: string }) {
return (
<div
className={[
'flex items-center justify-between rounded-xl border border-white/5 bg-neutral-900/40',
'px-4 py-3 shadow-[inset_0_1px_0_0_rgba(255,255,255,.04)] backdrop-blur',
'rounded-xl border border-white/6 bg-neutral-900/40 p-3 backdrop-blur',
'shadow-[inset_0_1px_0_rgba(255,255,255,.04)]',
className,
].join(' ')}
>
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-white/60">{label}</div>
<div className="mt-0.5 text-2xl font-semibold text-white">{value}</div>
</div>
{icon && (
<div className="ml-3 grid h-10 w-10 place-items-center rounded-lg ring-1 ring-inset ring-white/10 bg-white/5 text-white/80">
{icon}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-white/60">{label}</div>
<div className="mt-0.5 text-2xl font-semibold text-white">{value}</div>
{hint && <div className="mt-0.5 text-xs text-white/50">{hint}</div>}
</div>
)}
{icon && (
<div className="grid h-10 w-10 place-items-center rounded-lg bg-white/5 ring-1 ring-inset ring-white/10 text-white/80">
{icon}
</div>
)}
</div>
</div>
)
}
// ── component ────────────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Card>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold tracking-wide text-white/80">{title}</h3>
</div>
{children}
</Card>
)
}
/* Hauptkomponente */
export default function StatsView({ steamId, stats }: Props) {
const { data: session } = useSession()
const matches = stats.matches ?? []
const allMatches = stats.matches ?? []
/* ─ Filter: 30 | 90 | Alle ─ */
const [range, setRange] = useState<'30' | '90' | 'all'>('30')
const matches = useMemo(() => {
if (range === 'all') return allMatches
const days = range === '30' ? 30 : 90
const minTs = Date.now() - days * 24 * 3600 * 1000
return allMatches.filter((m) => new Date(m.date).getTime() >= minTs)
}, [allMatches, range])
/* ─ Kennzahlen ─ */
const totalKills = matches.reduce((s, m) => s + (m.kills ?? 0), 0)
const totalDeaths = matches.reduce((s, m) => s + (m.deaths ?? 0), 0)
const totalAssists = matches.reduce((s, m) => s + (m.assists ?? 0), 0)
const totalDamage = matches.reduce((s, m) => s + (m.totalDamage ?? 0), 0)
// ►► ADR-Berechnung
const totalRounds = matches.reduce((s, m) => s + getRounds(m), 0)
const adrOverall = totalRounds > 0 ? totalDamage / totalRounds : (matches.length ? totalDamage / matches.length : 0)
// --- KPI-Basiswerte ---
const totalKills = matches.reduce((sum, m) => sum + (m.kills ?? 0), 0)
const totalDeaths = matches.reduce((sum, m) => sum + (m.deaths ?? 0), 0)
const totalAssists = matches.reduce((sum, m) => sum + (m.assists ?? 0), 0)
const totalDamage = matches.reduce((sum, m) => sum + (m.totalDamage ?? 0), 0)
const overallKD = kd(totalKills, totalDeaths)
const dateLabels = matches.map((m) => fmtShortDate(m.date))
const premierMatches = matches.filter((m) => m.rankNew !== null && m.matchType === 'premier')
const compMatches = matches.filter((m) => m.rankNew !== null && m.matchType !== 'premier')
/* Form: letzte 12 K/D */
const last = matches.slice(-12)
const kdSeries = last.map((m) => kd(m.kills ?? 0, m.deaths ?? 0))
const lastKD = kdSeries.at(-1) ?? 0
const prevKD =
kdSeries.length > 1 ? kdSeries.slice(0, -1).reduce((a, b) => a + b, 0) / (kdSeries.length - 1) : lastKD
const deltaKD = lastKD - prevKD
const dateLabels = matches.map((m) => fmtDate(m.date))
// --- Lokale Aggregationen für andere Charts ---
/* Aggregat: Kills je Map */
const killsPerMap = useMemo(() => {
return matches.reduce<Record<string, number>>((acc, m) => {
const k = normMapKey(m.map)
acc[k] = (acc[k] || 0) + (m.kills ?? 0)
const key = (m.map ?? '').toLowerCase().replace(/^.*\//, '').replace(/\.bsp$/, '')
acc[key] = (acc[key] ?? 0) + (m.kills ?? 0)
return acc
}, {})
}, [matches])
const mapKeys = Object.keys(killsPerMap)
const mapNames = mapKeys.map(humanizeMap)
const mapLabels = mapKeys.map((k) =>
k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
)
// --- WINRATE je Map vom API-Endpoint laden ---
/* Winrate je Map (vom API) */
const [wrLabels, setWrLabels] = useState<string[]>([])
const [wrValues, setWrValues] = useState<number[]>([])
useEffect(() => {
let aborted = false
let stop = false
;(async () => {
try {
const r = await fetch(`/api/user/${steamId}/winrate`, { cache: 'no-store' })
if (!r.ok) throw new Error('Winrate-Laden fehlgeschlagen')
if (!r.ok) throw 0
const json: { labels: string[]; values: number[] } = await r.json()
if (!aborted) {
setWrLabels(json.labels || [])
setWrValues(json.values || [])
if (!stop) {
setWrLabels(json.labels ?? [])
setWrValues(json.values ?? [])
}
} catch (e) {
if (!aborted) {
} catch {
if (!stop) {
setWrLabels([])
setWrValues([])
}
}
})()
return () => { aborted = true }
return () => {
stop = true
}
}, [steamId])
const winPct = wrValues.map(v => Math.max(0, Math.min(100, v ?? 0)));
const lossPct = winPct.map(v => 100 - v);
// ►► Per-Match-ADR Serie für Chart
const adrPerMatch = matches.map((m) => {
const r = getRounds(m)
const dmg = m.totalDamage ?? 0
return r > 0 ? dmg / r : dmg // Fallback falls Runden fehlen
})
return (
<div className="space-y-6">
{/* KPI row */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Pill
label="Matches"
value={fmtInt(matches.length)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
}
/>
<Pill
label="Kills"
value={fmtInt(totalKills)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M12 19V5M5 12h14" />
</svg>
}
/>
<Pill
label="K/D"
value={overallKD === Infinity ? '∞' : overallKD.toFixed(2)}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M3 3v18M21 21H9" />
</svg>
}
className={`ring-1 ring-inset ${tintForKD(overallKD)}`}
/>
<Pill
label="Damage (Summe)"
value={fmtInt(Math.round(totalDamage))}
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="1.7">
<path d="M6 20 18 4M14 20h6v-6" />
</svg>
}
/>
{/* Range Filter */}
<div className="flex items-center justify-between">
<div className="text-xs text-white/50">
Auswertung für{' '}
<span className="text-white/80 font-medium">
{range === 'all' ? `${allMatches.length}` : `${matches.length}`}
</span>{' '}
Matches
</div>
<div className="inline-flex overflow-hidden rounded-lg border border-white/10 bg-neutral-900/60">
{(['30', '90', 'all'] as const).map((r) => (
<button
key={r}
className={[
'px-3 py-1.5 text-sm transition',
range === r ? 'bg-white/10 text-white' : 'text-white/70 hover:bg-white/5',
].join(' ')}
onClick={() => setRange(r)}
>
{r === 'all' ? 'Alle' : `${r} Tage`}
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
{/* left column (compact circles) */}
<div className="space-y-4 lg:col-span-1">
<Card>
{/* Top Row: Form + KPIs */}
<Card maxWidth="full">
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-wide text-white/60">Aktuelle Form</div>
<div className="mt-1 flex items-center gap-2">
<span
className={['rounded-md px-2 py-1 text-sm font-semibold ring-1 ring-inset', tintForKD(overallKD)].join(
' ',
)}
>
Ø K/D&nbsp;{kdLabel(totalKills, totalDeaths)}
</span>
<span
className={[
'rounded-md px-2 py-1 text-xs ring-1 ring-inset',
deltaKD >= 0
? 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/20'
: 'text-rose-400 bg-rose-500/10 ring-rose-500/20',
].join(' ')}
>
{deltaKD >= 0 ? '▲' : '▼'} {Math.abs(deltaKD).toFixed(2)}
</span>
</div>
</div>
<Sparkline values={kdSeries} />
</div>
<div className="grid grid-cols-2 gap-3 md:w-[360px]">
<Metric label="Kills" value={fmtInt(totalKills)} />
<Metric label="Deaths" value={fmtInt(totalDeaths)} />
</div>
</div>
</Card>
{/* KPI Grid */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Matches" value={fmtInt(matches.length)} />
<Metric label="Assists" value={fmtInt(totalAssists)} />
<Metric
label="K/D"
value={overallKD === Infinity ? '∞' : overallKD.toFixed(2)}
className={['ring-1 ring-inset', tintForKD(overallKD)].join(' ')}
/>
{/* ▼ NEU: ADR statt Gesamtdamage */}
<Metric label="ADR (Ø Damage pro Match)" value={fmtADR(adrOverall)} />
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-4">
{/* left column */}
<div className="space-y-6 xl:col-span-1">
<Section title="Verteilung Kills / Deaths">
<div className="relative mx-auto">
<Chart
type="doughnut"
title="Ø Gesamt-K/D"
labels={['Kills', 'Deaths']}
datasets={[
{
label: 'Anzahl',
data: [totalKills, totalDeaths],
backgroundColor: [tone.blue, tone.red],
},
{ label: 'Anteile', data: [totalKills, totalDeaths], backgroundColor: [tone.blue, tone.red] },
]}
hideLabels
options={{ plugins: { legend: { position: 'bottom' } } }}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
className={[
'rounded-md px-2 py-1 text-lg font-semibold ring-1 ring-inset',
tintForKD(overallKD),
].join(' ')}
className={['rounded-md px-2 py-1 text-lg font-semibold ring-1 ring-inset', tintForKD(overallKD)].join(
' ',
)}
>
{overallKD === Infinity ? '∞' : overallKD.toFixed(2)}
</div>
</div>
</div>
</Card>
</Section>
<Card>
<Section title="Kills • Assists • Deaths">
<Chart
type="doughnut"
title="Kills vs Assists vs Deaths"
labels={['Kills', 'Assists', 'Deaths']}
datasets={[
{
label: 'Anteile',
data: [totalKills, totalAssists, totalDeaths],
backgroundColor: [tone.blue, tone.amber, tone.red],
},
{ label: 'Anteile', data: [totalKills, totalAssists, totalDeaths], backgroundColor: [tone.blue, tone.amber, tone.red] },
]}
options={{ plugins: { legend: { position: 'bottom' } } }}
/>
</Card>
</div>
</Section>
{/* right column (wide charts) */}
<div className="space-y-6 lg:col-span-3">
<Card>
<Section title="Win / Loss je Map (%)">
{wrLabels.length === 0 ? (
<div className="rounded-md border border-dashed border-white/10 p-6 text-center text-sm text-white/60">
Keine Daten.
</div>
) : (
<Chart
type="bar"
title="Kills pro Match"
labels={dateLabels}
labels={wrLabels}
datasets={[
{
label: 'Kills',
data: matches.map((m) => m.kills ?? 0),
backgroundColor: tone.blue,
label: 'Win %',
data: winPct,
backgroundColor: 'rgba(16,185,129,.85)', // emerald
},
{
label: 'Loss %',
data: lossPct,
backgroundColor: 'rgba(239,68,68,.85)', // red
},
]}
options={{
indexAxis: 'y',
responsive: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: (ctx: any) => `${ctx.dataset.label}: ${Number(ctx.parsed.x).toFixed(0)}%`,
},
},
},
scales: {
x: {
stacked: true,
min: 0,
max: 100,
ticks: {
callback: (v) => `${v}%`,
},
grid: { color: 'rgba(255,255,255,.08)' },
},
y: {
stacked: true,
grid: { display: false },
},
},
}}
/>
</Card>
)}
</Section>
</div>
<Card>
{/* right column */}
<div className="space-y-6 xl:col-span-3">
<Section title="Kills pro Match">
<Chart
type="bar"
labels={dateLabels}
datasets={[{ label: 'Kills', data: matches.map((m) => m.kills ?? 0), backgroundColor: tone.blue }]}
options={{ scales: { x: { grid: { display: false } }, y: { grid: { color: 'rgba(255,255,255,.08)' } } } }}
/>
</Section>
<Section title="K/D Ratio pro Match">
<Chart
type="line"
title="K/D Ratio pro Match"
labels={dateLabels}
datasets={[
{
label: 'K/D',
data: matches.map((m) =>
kd(m.kills, m.deaths) === Infinity
? (m.kills ?? 0)
: (m.kills ?? 0) / Math.max(1, m.deaths ?? 0)
(m.deaths ?? 0) > 0 ? (m.kills ?? 0) / (m.deaths ?? 1) : (m.kills ?? 0),
),
borderColor: tone.red,
backgroundColor: tone.redBg,
borderWidth: 2,
fill: true,
},
]}
options={{ scales: { x: { grid: { display: false } }, y: { grid: { color: 'rgba(255,255,255,.08)' } } } }}
/>
</Card>
</Section>
<Card>
<Chart
type="line"
title="Headshot % pro Match"
labels={dateLabels}
datasets={[
{
label: 'HS%',
data: matches.map((m) => m.headshotPct ?? 0),
borderColor: tone.violet,
backgroundColor: tone.violetBg,
borderWidth: 2,
},
]}
/>
</Card>
<Card>
{/* ▼ NEU: ADR pro Match */}
<Section title="ADR pro Match">
<Chart
type="bar"
title="Gesamtdamage pro Match"
labels={dateLabels}
datasets={[
{
label: 'Damage',
data: matches.map((m) => m.totalDamage ?? 0),
backgroundColor: tone.amber,
datasets={[{ label: 'ADR', data: adrPerMatch, backgroundColor: tone.amber }]}
options={{
scales: {
x: { grid: { display: false } },
y: { grid: { color: 'rgba(255,255,255,.08)' } },
},
]}
plugins: { tooltip: { callbacks: { label: (c) => `ADR: ${fmtADR(c.parsed.y as number)}` } } },
}}
/>
</Card>
</Section>
{premierMatches.length > 0 && (
<Card>
<Chart
type="line"
title="Premier Rank-Verlauf"
labels={premierMatches.map((m) => fmtDate(m.date))}
datasets={[
{
label: 'Premier Rank',
data: premierMatches.map((m) => m.rankNew ?? 0),
borderColor: tone.teal,
backgroundColor: tone.tealBg,
borderWidth: 2,
},
]}
/>
</Card>
)}
{compMatches.length > 0 && (
<Card>
<Chart
type="line"
title="Competitive Rank-Verlauf"
labels={compMatches.map((m) => fmtDate(m.date))}
datasets={[
{
label: 'Comp Rank',
data: compMatches.map((m) => m.rankNew ?? 0),
borderColor: tone.orange,
backgroundColor: tone.orangeBg,
borderWidth: 2,
},
]}
/>
</Card>
)}
<Card>
<Section title="Kills pro Map">
<Chart
type="bar"
title="Kills pro Map"
labels={mapNames}
datasets={[
{
label: 'Kills',
data: mapKeys.map((k) => killsPerMap[k]),
backgroundColor: tone.orange,
},
]}
labels={mapLabels}
datasets={[{ label: 'Kills', data: mapKeys.map((k) => killsPerMap[k]), backgroundColor: tone.orange }]}
options={{
indexAxis: 'y',
scales: { x: { grid: { color: 'rgba(255,255,255,.08)' } }, y: { grid: { display: false } } },
}}
/>
</Card>
</Section>
{/* 👇 Radar: Winrate je Map (ersetzt „Matches pro Map“) */}
<Card>
<Section title="Winrate je Map (%)">
<Chart
type="radar"
title="Winrate je Map (%)"
labels={wrLabels}
datasets={[
{
label: 'Winrate',
data: wrValues, // Prozentwerte 0..100
backgroundColor: tone.blueBg,
borderColor: tone.blue,
borderWidth: 2,
},
{ label: 'Winrate', data: wrValues, backgroundColor: tone.tealBg, borderColor: tone.teal, borderWidth: 2 },
]}
radarIconLabels
radarIconLabelColor="#fff"
radarMax={100}
radarStepSize={20}
options={{ plugins: { legend: { display: false } } }}
/>
</Card>
</Section>
</div>
</div>
</div>

View File

@ -0,0 +1,139 @@
// /src/app/profile/[steamId]/ProfileHeader.tsx
import Link from 'next/link'
import { Tabs } from '../../components/Tabs' // ⬅️ deine Tabs-Komponente
type Props = {
user: {
steamId: string
name: string | null
avatar: string | null
premierRank: number | null
status: 'online' | 'away' | 'offline'
lastActiveAt: Date | null
vacBanned: boolean | null
numberOfVACBans: number | null
numberOfGameBans: number | null
daysSinceLastBan: number | null
communityBanned: boolean | null
economyBan: string | null
lastBanCheck: Date | null
}
}
function timeAgo(d?: Date | null) {
if (!d) return 'unbekannt'
const sec = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000))
const units: [number, string][] = [
[60, 'Sek.'], [60, 'Min.'], [24, 'Std.'], [7, 'Tg.'], [4.35, 'Wo.'],
[12, 'Mon.'], [Number.POSITIVE_INFINITY, 'J.'],
]
let value = sec
let label = 'Sek.'
for (const [k, l] of units) {
if (value < k) { label = l; break }
value = Math.floor(value / k)
label = l
}
return `${value} ${label}`
}
function StatusDot({ s }: { s: Props['user']['status'] }) {
const map: Record<string, string> = {
online: 'bg-emerald-500',
away: 'bg-amber-400',
offline: 'bg-neutral-500',
}
return <span className={`inline-block size-2.5 rounded-full ${map[s]}`} />
}
function BanBadge({ u }: { u: Props['user'] }) {
const hasAny =
!!u.vacBanned ||
(u.numberOfGameBans ?? 0) > 0 ||
!!u.communityBanned ||
(u.economyBan && u.economyBan !== 'none')
const updated = u.lastBanCheck ? ` • geprüft: ${u.lastBanCheck.toLocaleDateString()}` : ''
if (!hasAny) {
return (
<div className="rounded-md border border-emerald-700/60 bg-emerald-900/30 p-3 text-sm text-emerald-200">
<span className="font-semibold">Keine Bans</span> auf diesem Account{updated}
</div>
)
}
return (
<div className="rounded-md border border-red-700/60 bg-red-900/30 p-3 text-sm text-red-100">
<div className="font-semibold mb-1">🚫 Einschränkungen/Bans{updated}</div>
<ul className="space-y-1 pl-4 list-disc">
{u.vacBanned && (
<li>
VAC gebannt ({u.numberOfVACBans ?? 1}×
{typeof u.daysSinceLastBan === 'number' ? `, letzte vor ${u.daysSinceLastBan} Tagen` : ''})
</li>
)}
{(u.numberOfGameBans ?? 0) > 0 && <li>Game Bans: {u.numberOfGameBans}</li>}
{u.communityBanned && <li>Community Ban aktiv</li>}
{u.economyBan && u.economyBan !== 'none' && <li>Economy: {u.economyBan}</li>}
</ul>
</div>
)
}
export default function ProfileHeader({ user: u }: Props) {
return (
<header className="flex flex-col gap-6">
{/* Top */}
<div className="flex items-start gap-5">
<img
src={u.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={u.name ?? ''}
width={88}
height={88}
className="rounded-full border border-neutral-700 bg-neutral-900 object-cover"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl md:text-3xl font-extrabold tracking-tight">
{u.name ?? 'Unbekannt'}
</h1>
{/* PremierRankBadge hier optional einfügen */}
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-sm text-neutral-400">
<code className="rounded bg-neutral-800/70 px-2 py-0.5 text-neutral-200">{u.steamId}</code>
<span className="hidden sm:inline"></span>
<span className="flex items-center gap-1">
<StatusDot s={u.status} />
<span className="capitalize">{u.status}</span>
{u.status !== 'online' && u.lastActiveAt && (
<span className="opacity-70">({timeAgo(u.lastActiveAt)} ago)</span>
)}
</span>
<span className="hidden sm:inline"></span>
<Link
href={`https://steamcommunity.com/profiles/${u.steamId}`}
target="_blank"
className="underline decoration-dotted hover:text-white"
>
Steam-Profil
</Link>
</div>
</div>
</div>
{/* Bans */}
<BanBadge u={u} />
{/* Tabs ersetzt die alte nav-Leiste */}
<Tabs
className="justify-start border-b border-neutral-700/60"
tabClassName="px-3 py-2 text-sm"
>
<Tabs.Tab name="Statistiken" href={`/profile/${u.steamId}/stats`} />
<Tabs.Tab name="Matches" href={`/profile/${u.steamId}/matches`} />
</Tabs>
</header>
)
}

View File

@ -1,40 +0,0 @@
// /src/app/components/UserHeader.tsx
import { Tabs } from '../../components/Tabs'
import PremierRankBadge from '../../components/PremierRankBadge'
type UserHeaderProps = {
steamId: string
name: string
avatar?: string | null
premierRank?: number | null
}
export default function UserHeader({ steamId, name, avatar, premierRank }: UserHeaderProps) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<img
src={avatar || '/default-avatar.png'}
alt={name ?? ''}
width={64}
height={64}
className="rounded-full"
/>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">{name}</h1>
{typeof premierRank === 'number' && (
<PremierRankBadge rank={premierRank} />
)}
</div>
<p className="text-sm text-gray-500">{steamId}</p>
</div>
</div>
<Tabs>
<Tabs.Tab name="Statistiken" href={`/profile/${steamId}/stats`} />
<Tabs.Tab name="Matches" href={`/profile/${steamId}/matches`} />
</Tabs>
</div>
)
}

View File

@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import Card from '../../components/Card'
import UserHeader from './UserHeader'
import ProfileHeader from './ProfileHeader'
export default async function ProfileLayout({
children,
@ -12,13 +12,28 @@ export default async function ProfileLayout({
children: ReactNode
params: { steamId: string }
}) {
const steamId = params.steamId
const user = await prisma.user.findUnique({
where: { steamId: params.steamId },
where: { steamId },
select: {
steamId: true,
name: true,
avatar: true,
premierRank: true,
premierRank: true,
// 👉 Status & Aktivität
status: true,
lastActiveAt: true,
// 👉 Ban-Felder direkt aus der DB
vacBanned: true,
numberOfVACBans: true,
numberOfGameBans: true,
daysSinceLastBan: true,
communityBanned: true,
economyBan: true,
lastBanCheck: true,
},
})
@ -26,9 +41,9 @@ export default async function ProfileLayout({
return (
<Card maxWidth="auto">
<div className="max-w-4xl mx-auto py-8 px-4 space-y-6">
<UserHeader steamId={user.steamId} name={user.name ?? ''} avatar={user.avatar} premierRank={user.premierRank} />
<div className="pt-6">{children}</div>
<div className="max-w-5xl mx-auto py-8 px-4 space-y-6">
<ProfileHeader user={user} />
<div className="pt-4">{children}</div>
</div>
</Card>
)

33
src/lib/steam.ts Normal file
View File

@ -0,0 +1,33 @@
// /src/lib/steam.ts
export type SteamBanInfo = {
SteamId: string
CommunityBanned: boolean
VACBanned: boolean
NumberOfVACBans: number
DaysSinceLastBan: number
NumberOfGameBans: number
EconomyBan: 'none' | 'probation' | 'banned' | string
};
export async function getPlayerBans(steamId: string, init?: RequestInit) {
const key = process.env.STEAM_API_KEY;
if (!key) {
console.warn('STEAM_API_KEY fehlt Ban-Infos werden nicht geladen.');
return null;
}
const url = new URL('https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/');
url.searchParams.set('key', key);
url.searchParams.set('steamids', steamId);
const res = await fetch(url.toString(), {
// In Profilen sinnvoll: leicht cachen
next: { revalidate: 300 },
...init,
});
if (!res.ok) return null;
const json = await res.json().catch(() => null);
const info: SteamBanInfo | undefined = json?.players?.[0];
return info ?? null;
}