updated
This commit is contained in:
parent
bd365390d5
commit
bacf848455
37
package-lock.json
generated
37
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {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}
|
||||
<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}
|
||||
<span className="text-white/60">D</span> {m.deaths}
|
||||
{Number.isFinite(m.assists) && (
|
||||
<>
|
||||
<span className="text-white/70">A</span> {m.assists}
|
||||
<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 {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 {kd === 2 && (m.deaths ?? 0) === 0 ? '∞' : kd.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 {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>
|
||||
|
||||
139
src/app/[locale]/profile/[steamId]/ProfileHeader.tsx
Normal file
139
src/app/[locale]/profile/[steamId]/ProfileHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
33
src/lib/steam.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user