updated profile

This commit is contained in:
Linrador 2025-10-02 15:00:48 +02:00
parent bacf848455
commit 16ce72b1a6
34 changed files with 1621 additions and 305 deletions

12
.env
View File

@ -33,3 +33,15 @@ NEXT_PUBLIC_CS2_GAME_WS_PATH=/game
NEXT_PUBLIC_CS2_GAME_WS_SCHEME=wss
NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000"
FACEIT_CLIENT_ID=a0bf42fd-73e8-401c-84d7-5a3a88ff28f6
FACEIT_CLIENT_SECRET=kx6uMZsBcXSm75Y7Sfj2nKtbtZOcxGVwsynvxBJ1
FACEIT_REDIRECT_URI=https://ironieopen.de/api/faceit/callback
# aus den Docs übernehmen:
FACEIT_AUTH_URL=...authorize endpoint...
FACEIT_TOKEN_URL=...token endpoint...
FACEIT_API_BASE=https://open.faceit.com/data/v4
FACEIT_SCOPES=openid profile email # je nach Bedarf erweitern
FACEIT_COOKIE_NAME=faceit_pkce # optional
FACEIT_COOKIE_SECRET=super-long-random-secret

View File

@ -68,6 +68,17 @@
economyBan String?
lastBanCheck DateTime?
// FaceIt Account
faceitId String? @unique
faceitNickname String?
faceitAvatar String?
faceitLinkedAt DateTime?
// Falls du Tokens speichern willst (siehe Security-Hinweise unten)
faceitAccessToken String?
faceitRefreshToken String?
faceitTokenExpiresAt DateTime?
@@index([vacBanned])
@@index([numberOfVACBans])
@@index([numberOfGameBans])

View File

@ -1,3 +1,5 @@
// Card.tsx
'use client'
import React from 'react'
@ -62,13 +64,12 @@ export default function Card({
<div
style={style}
className={[
// ⬇️ kein Außen-Padding; Box misst inkl. Border (verhindert Extra-Scroll)
'box-border flex flex-col rounded-xl border border-gray-200 bg-white shadow-2xs overflow-hidden max-h-full',
'dark:bg-neutral-800 dark:border-neutral-700 dark:shadow-neutral-700/70',
alignClasses,
widthClasses[maxWidth],
// wenn parent `h-full` gibt, füllt die Card die Höhe
'min-h-0'
'min-h-0',
'box-border'
].join(' ')}
>
{(title || description) && (
@ -80,8 +81,8 @@ export default function Card({
</div>
)}
<div className="flex-1 min-h-0 overflow-auto">
<div className="p-4 sm:p-6">
<div className={`flex-1 min-h-0 ${bodyScrollable ? 'overflow-auto overscroll-contain' : ''}`}>
<div className="p-4 sm:p-6 h-full min-h-0">
{children}
</div>
</div>

View File

@ -0,0 +1,47 @@
import React from 'react';
import clsx from 'clsx';
type Tone = 'danger' | 'warn' | 'ok' | 'neutral';
type Props = {
/** Text/Content der Pill, z. B. "VAC" oder "BAN" */
children: React.ReactNode;
/** Tooltip wie ban.tooltip */
title?: string;
/** Sichtbarkeit beibehalten wie im Beispiel mit "invisible" */
visible?: boolean; // default: true
/** Farbton */
tone?: Tone; // default: 'danger'
/** Zusätzliche Klassen, z.B. "ml-1" */
className?: string;
};
const toneClass: Record<Tone, string> = {
danger : 'bg-red-600 text-white',
warn : 'bg-amber-500 text-white',
ok : 'bg-emerald-600 text-white',
neutral: 'bg-white/10 text-white',
};
export default function Pill({
children,
title,
visible = true,
tone = 'danger',
className,
}: Props) {
return (
<span
title={title}
className={clsx(
// exakt wie im MatchesList VAC/BAN <span>
'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-bold',
toneClass[tone],
visible ? '' : 'invisible',
className
)}
>
{children}
</span>
);
}

View File

@ -1,5 +1,4 @@
'use client'
import {useEffect, useRef, useState, type RefObject} from 'react'
export type SpyItem = { id: string; label: string }
@ -10,11 +9,8 @@ type Props = {
className?: string
activeClassName?: string
inactiveClassName?: string
/** Fixer Pixel-Offset (z. B. fixe Headerhöhe). Default 0 */
offset?: number
/** Hash der URL synchronisieren, wenn Section aktiv wird */
updateHash?: boolean
/** Dauer des programmatic scrolls (ms) sperrt solange den Observer */
smoothMs?: number
}
@ -33,12 +29,20 @@ export default function ScrollSpyTabs({
const isProgrammaticRef = useRef(false)
const progTimerRef = useRef<number | null>(null)
// Helper: sichere Query (mit CSS.escape Fallback)
const setActive = (id: string) => {
if (id && id !== activeId) {
setActiveId(id)
if (updateHash) history.replaceState(null, '', `#${id}`)
}
}
// sichere Query
const qs = (root: Document | HTMLElement, id: string) => {
const esc = (window as any).CSS?.escape?.(id) ?? id.replace(/([ #.;?+*~\\':"!^$[\]()=>|\/@])/g, '\\$1')
return root.querySelector<HTMLElement>(`#${esc}`)
}
/* -------- IntersectionObserver: „mittlere“ Logik -------- */
useEffect(() => {
const rootEl = containerRef?.current ?? null
const rootNode: Document | HTMLElement = rootEl ?? document
@ -49,8 +53,25 @@ export default function ScrollSpyTabs({
observerRef.current?.disconnect()
observerRef.current = new IntersectionObserver(
() => {
if (isProgrammaticRef.current) return // während Smooth-Scroll keine Auto-Umschaltung
if (isProgrammaticRef.current) return
// ⬇️ Top/Bottom bevorzugen
const firstId = items[0]?.id
const lastId = items[items.length - 1]?.id
const EPS = 1
if (rootEl) {
const atTop = rootEl.scrollTop <= EPS
const atBottom = Math.ceil(rootEl.scrollTop + rootEl.clientHeight) >= rootEl.scrollHeight - EPS
if (atTop && firstId) { setActive(firstId); return }
if (atBottom && lastId){ setActive(lastId); return }
} else {
const atTop = window.scrollY <= EPS
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - EPS
if (atTop && firstId) { setActive(firstId); return }
if (atBottom && lastId){ setActive(lastId); return }
}
// „mittlere Linie“-Logik
const rootTop = rootEl ? rootEl.getBoundingClientRect().top : 0
const targetLine = rootTop + offset + 1
@ -61,11 +82,7 @@ export default function ScrollSpyTabs({
const isCandidate = top <= targetLine + 1
if (isCandidate && (best === null || dist < best.dist)) best = { id: s.id, dist }
}
const nextActive = best?.id ?? sections[0].id
if (nextActive !== activeId) {
setActiveId(nextActive)
if (updateHash) history.replaceState(null, '', `#${nextActive}`)
}
setActive(best?.id ?? sections[0].id)
},
{
root: rootEl ?? null,
@ -76,20 +93,54 @@ export default function ScrollSpyTabs({
sections.forEach(s => observerRef.current!.observe(s))
return () => observerRef.current?.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, containerRef, offset, updateHash])
// ⬇️ WICHTIG: auf das ELEMENt hören, nicht nur auf das Ref-Objekt
}, [containerRef?.current, items, offset, updateHash])
/* -------- NEU: Kanten-Logik (Top/Bottom bevorzugen) -------- */
useEffect(() => {
const el = containerRef?.current
const target: any = el ?? window
const firstId = items[0]?.id
const lastId = items[items.length - 1]?.id
const EPS = 1
const onScrollOrResize = () => {
if (isProgrammaticRef.current) return
if (!firstId || !lastId) return
if (el) {
const atTop = el.scrollTop <= EPS
const atBottom = Math.ceil(el.scrollTop + el.clientHeight) >= el.scrollHeight - EPS
if (atTop) return setActive(firstId)
if (atBottom) return setActive(lastId)
} else {
const atTop = window.scrollY <= EPS
const atBottom = Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight - EPS
if (atTop) return setActive(firstId)
if (atBottom) return setActive(lastId)
}
}
target.addEventListener('scroll', onScrollOrResize, { passive: true })
window.addEventListener('resize', onScrollOrResize, { passive: true })
onScrollOrResize()
return () => {
target.removeEventListener('scroll', onScrollOrResize)
window.removeEventListener('resize', onScrollOrResize)
}
// ⬇️ ebenfalls auf das Element selbst hören
}, [containerRef?.current, items])
/* -------- programmatic scroll (Tabs-Klick) -------- */
const onJump = (id: string) => {
const rootEl = containerRef?.current ?? null
const rootNode: Document | HTMLElement = rootEl ?? document
const el = qs(rootNode, id)
if (!el) return
// sofort aktiv setzen (optimistisch)
setActiveId(id)
if (updateHash) history.replaceState(null, '', `#${id}`)
setActive(id)
// Observer kurz blockieren
isProgrammaticRef.current = true
if (progTimerRef.current) window.clearTimeout(progTimerRef.current)
progTimerRef.current = window.setTimeout(() => {

View File

@ -1,5 +1,9 @@
// /src/app/components/profile/[steamId]/Profile.tsx
import Link from 'next/link'
import Card from '../../Card'
import PremierRankBadge from '../../PremierRankBadge'
import CompRankBadge from '../../CompRankBadge'
import { MAP_OPTIONS } from '@/lib/mapOptions'
type Props = { steamId: string }
@ -15,13 +19,13 @@ type ApiStats = {
}>
}
/* ───────── helpers ───────── */
/* ───────── helpers (KPIs oben) ───────── */
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 ? 2 : k / Math.max(1, d)) // clamp nach oben später
const kdRaw = (k: number, d: number) => (d <= 0 ? 2 : k / Math.max(1, d))
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' :
@ -36,7 +40,7 @@ async function getStats(steamId: string): Promise<ApiStats | null> {
return res.json()
}
/* ───────── UI atoms ───────── */
/* ───────── kleine UI-Bausteine (KPIs oben) ───────── */
function Chip({
label, value, icon, className = '',
}: { label: string; value: string; icon?: React.ReactNode; className?: string }) {
@ -84,34 +88,26 @@ function CtaCard({ title, desc, href, tone = 'blue' }:{
)
}
/** einfacher Donut-Gauge für K/D rein als SVG */
/** Donut-Gauge (K/D) */
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
value >= 1.4 ? '#34d399' :
value >= 1.05 ? '#60a5fa' :
value >= 0.9 ? '#fbbf24' : '#fb7185'
return (
<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>
@ -132,6 +128,132 @@ function Sparkline({ values }: { values: number[] }) {
)
}
/* ───────── MatchesList-Helper (kopiert/angepasst) ───────── */
type MatchRow = {
matchId?: string | number | null
id?: string | number | null
date: string
map: string
kills: number
deaths: number
assists?: number | null
totalDamage?: number | null
roundCount?: number | null
scoreA?: number | null
scoreB?: number | null
score?: string | null
team?: 'A' | 'B' | 'CT' | 'T' | null
winnerTeam?: 'CT' | 'T' | null
result?: 'win' | 'loss' | 'draw' | null
matchType?: 'premier' | 'competitive' | string | null
rankNew?: number | null
rankChange?: number | null
aim?: number | string | null
hasBanned?: boolean | null
hasVac?: boolean | null
bannedCount?: number | null
banTooltip?: string | null
}
const normKey = (raw: string) => (raw || '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
const labelForMap = (raw: string) => {
const k = normKey(raw)
return (
MAP_OPTIONS.find(o => o.key === k)?.label ??
k.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
)
}
const iconForMap = (raw: string) => {
const k = normKey(raw)
const known = MAP_OPTIONS.some(o => o.key === k || o.key === `de_${k}`)
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return known ? `/assets/img/mapicons/map_icon_${withPrefix}.svg` : `/assets/img/mapicons/map_icon_lobby_mapveto.svg`
}
const bgForMap = (raw: string) => {
const k = normKey(raw)
const opt: any = MAP_OPTIONS.find(o => o.key === k)
if (opt?.images?.length) return String(opt.images[0])
const withPrefix = k.startsWith('de_') ? k : `de_${k}`
return `/assets/img/maps/${withPrefix}.webp`
}
const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
const parseScoreString = (raw?: string | null): [number | null, number | null] => {
if (!raw) return [null, null]
const [a, b] = raw.split(':').map(s => Number(s.trim()))
return [Number.isFinite(a) ? a : null, Number.isFinite(b) ? b : null]
}
const scoreOf = (m: MatchRow): [number | null, number | null] => {
const [sa, sb] = parseScoreString(m.score)
if (sa !== null && sb !== null) return [sa, sb]
const a = isFiniteNum(m.scoreA) ? m.scoreA! : null
const b = isFiniteNum(m.scoreB) ? m.scoreB! : null
return [a, b]
}
const inferOwnSide = (m: MatchRow, a: number | null, b: number | null): 'A' | 'B' | null => {
if (m.team === 'A' || m.team === 'B') return m.team
if (a === null || b === null) return null
if (m.result === 'win') return a > b ? 'A' : a < b ? 'B' : null
if (m.result === 'loss') return a < b ? 'A' : a > b ? 'B' : null
if (m.result === 'draw') return null
if (typeof m.rankChange === 'number') {
if (m.rankChange > 0) return a > b ? 'A' : a < b ? 'B' : null
if (m.rankChange < 0) return a < b ? 'A' : a > b ? 'B' : null
}
return null
}
const normalizeScore = (m: MatchRow, a: number | null, b: number | null): [number | null, number | null] => {
if (a === null || b === null) return [a, b]
if (m.team === 'CT') return [a, b]
if (m.team === 'T') return [b, a]
const side = inferOwnSide(m, a, b)
if (side === 'A') return [a, b]
if (side === 'B') return [b, a]
return [a, b]
}
const computeResultFromOwn = (own: number | null, opp: number | null): 'win' | 'loss' | 'draw' | 'match' => {
if (own === null || opp === null) return 'match'
if (own > opp) return 'win'
if (own < opp) return 'loss'
return 'draw'
}
const kdr = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number'
? (d === 0 ? '∞' : (k / d).toFixed(2))
: '-'
const adr = (dmg?: number | null, rounds?: number | null) =>
typeof dmg === 'number' && typeof rounds === 'number' && rounds > 0
? (dmg / rounds).toFixed(1)
: '-'
function Pill({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-black/30 px-2 py-0.5 text-xs text-neutral-200">
<span className="text-white/70">{label}</span>
<span className="tabular-nums">{value}</span>
</span>
)
}
function getBanBadgeFromRow(m: MatchRow) {
const count = Number(m.bannedCount ?? 0);
const hasBan = (m.hasBanned ?? false) || count > 0;
const hasVac = m.hasVac ?? false;
const label = hasVac ? 'VAC' : 'BAN';
const tooltip = m.banTooltip ?? undefined;
return { hasBan, hasVac, label, tooltip };
}
/* nur die letzten N holen */
async function getRecentMatches(steamId: string, n = 3) {
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
const res = await fetch(`${base}/api/user/${steamId}/matches?types=premier,competitive&limit=${n}`, { cache: 'no-store' })
if (!res.ok) return [] as MatchRow[]
const json = await res.json()
const all = (Array.isArray(json) ? json : json.items) as MatchRow[]
all.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
return all.slice(0, n)
}
/* ───────── page ───────── */
export default async function Profile({ steamId }: Props) {
const data = await getStats(steamId)
@ -146,88 +268,92 @@ export default async function Profile({ steamId }: Props) {
const kdVal = kdRaw(kills, deaths)
const kdClass = kdTone(kdVal)
// 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
const KD_CAP = 5.0; // max. Wert für K/D im Form-Chart
// 1) zeitlich (alt -> neu) sortieren, damit wir die "letzten" sicher erwischen
const sortedByDate = [...matches].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
)
// 2) letzte N Spiele holen (z.B. 12)
const last = sortedByDate.slice(-12)
// 3) stabile K/D-Serie erzeugen (Infinity abfangen & deckeln)
const kdSeries = last.map(m => {
const k = m.kills ?? 0
const d = m.deaths ?? 0
const v = d > 0 ? k / d : KD_CAP
return Math.min(KD_CAP, Math.max(0, v))
})
// 4) Delta berechnen (letzter Punkt vs. Ø der vorherigen)
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 = kdSeries.length > 1 ? lastKD - prevKD : 0
// 5) Sparkline robust füttern
const sparkValues = kdSeries.length >= 2 ? kdSeries : [0, 0]
// Highlights (aus verfügbaren Daten, keine neue API)
const avgDmgPerMatch = games ? damage / games : 0
const avgAstPerMatch = games ? assists / games : 0
const avgKillsPerMatch = games ? kills / games : 0
const recent = matches.slice(0, 6)
// ▼ NEU: echte zuletzt gespielte Matches wie MatchesList
const recent = await getRecentMatches(steamId, 3)
return (
<div className="space-y-8">
{/* ——— Hero-Banner mit Grid/Noise ——— */}
{/* ——— Hero-Banner ——— */}
<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',
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)}
{kdSeries.length > 1 ? (deltaKD >= 0 ? '▲' : '▼') : ''}{' '}
{kdSeries.length > 1 ? Math.abs(deltaKD).toFixed(2) : '—'}
</div>
</div>
<div className="mt-2 text-blue-400">
<Sparkline values={kdSeries}/>
<Sparkline values={sparkValues} />
</div>
</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/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 className="text-[11px] uppercase tracking-wide text-white/65">Ø Damage / Match</div>
<div className="mt-0.5 font-semibold text-white">{fmtInt(avgDmgPerMatch)}</div>
</div>
</div>
</div>
</div>
{/* ——— kompakte KPI-Chips ——— */}
{/* KPIs */}
<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>
} />
<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 / Match" value={fmtInt(avgDmgPerMatch)} 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>
{/* ——— Highlights + CTAs ——— */}
{/* 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>
@ -235,9 +361,9 @@ export default async function Profile({ steamId }: Props) {
<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 className="text-[11px] uppercase tracking-wide text-white/55">Ø Kills / Match</div>
<div className="mt-1 text-2xl font-semibold text-white">{fmtInt(avgKillsPerMatch)}</div>
<p className="mt-1 text-sm text-white/60">Durchschnittliche Kills pro Spiel.</p>
</div>
<div className="grid gap-4">
<CtaCard title="Statistiken" desc="Charts, Verläufe und Map-Auswertungen." href={`/profile/${steamId}/stats`} tone="blue" />
@ -245,8 +371,8 @@ export default async function Profile({ steamId }: Props) {
</div>
</div>
{/* ——— Timeline: letzte Matches ——— */}
<Card>
{/* ——— Letzte Matches (wie MatchesList) ——— */}
<Card maxWidth='full'>
<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">
@ -259,46 +385,132 @@ export default async function Profile({ steamId }: Props) {
<p className="text-sm text-white/60">Noch keine Daten.</p>
</div>
) : (
<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 kd = kdRaw(m.kills ?? 0, m.deaths ?? 0)
const tone = kdTone(kd)
return (
<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="space-y-3">
{recent.map((m, idx) => {
const linkId = String(m.matchId ?? m.id ?? '')
const href = linkId ? `/match-details/${linkId}` : undefined
const [scA, scB] = scoreOf(m)
const [ownScore, oppScore] = normalizeScore(m, scA, scB)
const result = m.result ?? computeResultFromOwn(ownScore, oppScore)
const ban = getBanBadgeFromRow(m)
const rowTint =
result === 'win'
? 'bg-emerald-500/[0.04] hover:bg-emerald-500/[0.07] border-emerald-700/40 hover:border-emerald-600/50'
: result === 'loss'
? 'bg-red-500/[0.04] hover:bg-red-500/[0.07] border-red-700/40 hover:border-red-600/50'
: result === 'draw'
? 'bg-amber-500/[0.04] hover:bg-amber-500/[0.07] border-amber-700/40 hover:border-amber-600/50'
: 'bg-neutral-900/40 hover:bg-neutral-900/70 border-neutral-700/60 hover:border-neutral-600'
const scoreColor =
result === 'win' ? 'text-emerald-300'
: result === 'loss' ? 'text-red-300'
: result === 'draw' ? 'text-amber-300'
: 'text-white/80'
const mapLabel = labelForMap(m.map)
const iconSrc = iconForMap(m.map)
const bgUrl = bgForMap(m.map)
const row = (
<div
className={`relative cursor-pointer rounded-lg border p-3 transition ${rowTint}
grid items-center gap-4
md:grid-cols-[200px_56px_minmax(360px,1fr)_max-content]`}
>
{/* Background + Vignette */}
<div aria-hidden className="pointer-events-none absolute inset-0 rounded-lg opacity-[0.06]"
style={{ backgroundImage: `url(${bgUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }} />
<div aria-hidden className="pointer-events-none absolute inset-0 rounded-lg"
style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.35) 100%)' }} />
{/* Map + Meta */}
<div className="relative z-[1] flex items-center gap-3 shrink-0">
<div className="grid h-12 w-12 place-items-center rounded-md bg-neutral-800/70 ring-1 ring-white/10 shrink-0 overflow-hidden">
<img src={iconSrc} alt={mapLabel} className="h-10 w-10 object-contain" loading="lazy" />
</div>
<div className="min-w-0">
<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/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/60">A</span> {m.assists}
</>
)}
</span>
<div className="text-xs text-neutral-300/90">{fmtDateTime(m.date)}</div>
<div className="truncate text-sm font-medium">
<span className="truncate">{mapLabel}</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', tone].join(' ')}>
K/D&nbsp;{kd === 2 && (m.deaths ?? 0) === 0 ? '∞' : kd.toFixed(2)}
</div>
{/* VAC/BAN Slot */}
<div className="hidden md:flex justify-center items-center">
<span
className={[
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-bold bg-red-600 text-white",
ban.hasBan ? "" : "invisible",
].join(" ")}
title={ban.tooltip}
>
{ban.label}
</span>
</div>
{/* Score + Pills */}
<div className="relative z-[1] flex w-full items-center gap-2 min-w-0 whitespace-nowrap">
<div className="inline-flex items-baseline leading-none">
<span className={`tabular-nums text-base font-semibold text-center ${scoreColor} w-[3ch]`}>
{ownScore ?? '-'}
</span>
<span className="text-white/50 text-center w-[1ch]" aria-hidden>:</span>
<span className={`tabular-nums text-base font-semibold text-center ${scoreColor} w-[3ch]`}>
{oppScore ?? '-'}
</span>
</div>
<span className="mx-2 h-4 w-px bg-white/10" />
<Pill label="K:" value={String(m.kills)} />
<Pill label="D:" value={String(m.deaths)} />
<Pill label="K/D:" value={kdr(m.kills, m.deaths)} />
<Pill label="ADR:" value={adr(m.totalDamage, m.roundCount)} />
</div>
</li>
{/* Rank-Block */}
<div className="ml-auto flex items-center justify-end gap-2 shrink-0 pr-1">
{m.matchType === 'premier' ? (
<>
<PremierRankBadge rank={m.rankNew ?? 0} />
<span
className={[
'w-[46px] text-center tabular-nums',
(m.rankChange ?? 0) > 0 ? 'text-emerald-300'
: (m.rankChange ?? 0) < 0 ? 'text-red-300'
: 'text-neutral-200',
].join(' ')}
title="Punkteänderung"
>
{m.rankChange != null ? `${m.rankChange > 0 ? '+' : ''}${m.rankChange}` : '\u00A0'}
</span>
</>
) : (
<>
<CompRankBadge rank={m.rankNew ?? 0} />
<span className="w-[46px]">&nbsp;</span>
</>
)}
</div>
</div>
)
return (
<div key={`${linkId || 'row'}-${idx}`}>
{href ? (
<Link href={href} className="block rounded-lg">
{row}
</Link>
) : (
row
)}
</div>
)
})}
</ul>
</div>
)}
</Card>
</div>

View File

@ -142,14 +142,34 @@ export default function StatsView({ steamId, stats }: Props) {
const overallKD = kd(totalKills, totalDeaths)
const dateLabels = matches.map((m) => fmtShortDate(m.date))
const KD_CAP = 5.0; // maximaler Wert für K/D im Form-Chart
/* 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
// 1) Zeitlich sortieren (alt → neu)
const sorted = useMemo(
() => [...matches].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()),
[matches]
);
// 2) Letzte 12
const last = sorted.slice(-12);
// 3) „sichere“ K/D-Serie: Infinity abfangen & deckeln
const kdSeries = last.map(m => {
const k = m.kills ?? 0;
const d = m.deaths ?? 0;
const v = d > 0 ? k / d : KD_CAP; // Deaths=0 => cap
return Math.min(KD_CAP, Math.max(0, v)); // zusätzlich begrenzen
});
// 4) Delta (letzter Punkt vs. Ø der vorherigen)
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;
/* Aggregat: Kills je Map */
const killsPerMap = useMemo(() => {

View File

@ -1,4 +1,4 @@
// AuthCodeSettings.tsx
// /src/app/[locale]/components/settings/account/AuthCodeSettings.tsx
'use client'

View File

@ -0,0 +1,71 @@
// /src/app/[locale]/components/settings/account/FaceitLink.tsx
'use client'
import { useState } from 'react'
import Button from '../../Button'
type Props = {
isLinked: boolean
nickname?: string | null // (unbenutzt, kann entfernt werden)
avatar?: string | null // (unbenutzt, kann entfernt werden)
}
export default function FaceitLink({ isLinked }: Props) {
const [loading, setLoading] = useState<'connect' | 'disconnect' | null>(null)
const onConnect = () => {
setLoading('connect')
window.location.assign('/api/faceit/login')
}
const onDisconnect = async () => {
setLoading('disconnect')
try {
await fetch('/api/faceit/disconnect', { method: 'POST' })
window.location.reload()
} catch {
setLoading(null)
}
}
return (
<div className="py-3 sm:py-4">
<div className="grid items-start gap-y-1.5 sm:gap-y-0 sm:gap-x-5 sm:grid-cols-12">
{/* Label & Beschreibung (links) */}
<div className="sm:col-span-4 2xl:col-span-2">
<label className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
FACEIT
</label>
<p className="mt-1 text-[13px] leading-5 text-gray-500 dark:text-neutral-500">
Verknüpfe deinen FACEIT-Account.
</p>
</div>
{/* Nur der Action-Button (rechts) */}
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
{isLinked ? (
<Button
onClick={onDisconnect}
disabled={loading !== null}
aria-busy={loading === 'disconnect'}
className="text-sm font-medium text-red-600 hover:text-red-500 disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading === 'disconnect' ? 'Trennen…' : 'Trennen'}
</Button>
) : (
<Button
onClick={onConnect}
color='blue'
//disabled={loading !== null}
disabled={true}
aria-busy={loading === 'connect'}
className="text-sm font-medium text-blue-600 hover:text-blue-500 disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading === 'connect' ? 'Verbinden…' : 'Mit FACEIT verbinden'}
</Button>
)}
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,7 @@
// /src/app/profile/[steamId]/ProfileHeader.tsx
import Link from 'next/link'
import { Tabs } from '../../components/Tabs' // ⬅️ deine Tabs-Komponente
import { Tabs } from '../../components/Tabs'
import Pill from '../../components/Pill'
type Props = {
user: {
@ -46,44 +47,16 @@ function StatusDot({ s }: { s: Props['user']['status'] }) {
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) {
const showVac = !!u.vacBanned || (u.numberOfVACBans ?? 0) > 0
const showGameBan = (u.numberOfGameBans ?? 0) > 0
const showComm = !!u.communityBanned
const showEcon = !!u.economyBan && u.economyBan !== 'none'
const showLastBan = typeof u.daysSinceLastBan === 'number'
const hasAnyBan = showVac || showGameBan || showComm || showEcon
return (
<header className="flex flex-col gap-6">
<header className="flex flex-col gap-5">
{/* Top */}
<div className="flex items-start gap-5">
<img
@ -95,14 +68,15 @@ export default function ProfileHeader({ user: u }: Props) {
/>
<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">
<h1 className="text-2xl md:text-3xl font-extrabold">
{u.name ?? 'Unbekannt'}
</h1>
{/* PremierRankBadge hier optional einfügen */}
{/* PremierRankBadge optional */}
</div>
{/* Meta-Zeile */}
<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>
<div className="rounded bg-neutral-800/70 px-2 py-0.5 text-neutral-200">{u.steamId}</div>
<span className="hidden sm:inline"></span>
<span className="flex items-center gap-1">
<StatusDot s={u.status} />
@ -115,25 +89,96 @@ export default function ProfileHeader({ user: u }: Props) {
<Link
href={`https://steamcommunity.com/profiles/${u.steamId}`}
target="_blank"
className="underline decoration-dotted hover:text-white"
rel="noopener noreferrer"
aria-label="Steam-Profil öffnen"
title="Steam-Profil"
className="inline-flex items-center justify-center rounded-md p-1.5
text-white/70 hover:text-white hover:bg-white/10 transition"
>
Steam-Profil
<i className="fab fa-steam text-lg" aria-hidden />
<span className="sr-only">Steam-Profil</span>
</Link>
</div>
{/* Kompakte Pills wie in MatchesList */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{hasAnyBan && showVac && (
<Pill
tone="danger"
title="VAC-Bans auf diesem Account"
className="ml-1"
>
<span>VAC</span>
{typeof u.numberOfVACBans === 'number' && (
<span className="tabular-nums">×{u.numberOfVACBans}</span>
)}
</Pill>
)}
{hasAnyBan && showGameBan && (
<Pill
tone="danger"
title="Game Bans (Spielbanns)"
className="ml-1"
>
<span>BAN</span>
<span className="tabular-nums">×{u.numberOfGameBans}</span>
</Pill>
)}
{hasAnyBan && showComm && (
<Pill
tone="danger"
title="Community Ban aktiv"
className="ml-1"
>
<span>COMM</span>
<span>BAN</span>
</Pill>
)}
{hasAnyBan && showEcon && (
<Pill
tone="warn"
title={`Economy Status: ${u.economyBan ?? ''}`}
className="ml-1"
>
<span>ECON</span>
<span className="uppercase">{u.economyBan}</span>
</Pill>
)}
{hasAnyBan && showLastBan && (
<Pill
tone="neutral"
title="Tage seit letztem Ban"
className="ml-1"
>
<span>Letzter&nbsp;Ban</span>
<span className="tabular-nums">{u.daysSinceLastBan} Tg.</span>
</Pill>
)}
{u.lastBanCheck && (
<Pill
tone="neutral"
title="Zeitpunkt der letzten Prüfung"
className="ml-1"
>
<span>geprüft</span>
<span>{u.lastBanCheck.toLocaleDateString()}</span>
</Pill>
)}
</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 */}
<Tabs className="justify-start border-neutral-700/60" tabClassName="px-3 py-2 text-sm">
<Tabs.Tab name="Übersicht" href={`/profile/${u.steamId}`} />
<Tabs.Tab name="Statistiken" href={`/profile/${u.steamId}/stats`} />
<Tabs.Tab name="Matches" href={`/profile/${u.steamId}/matches`} />
</Tabs>
</header>
)
}
}

View File

@ -12,38 +12,39 @@ export default async function ProfileLayout({
children: ReactNode
params: { steamId: string }
}) {
const steamId = params.steamId
const { steamId } = params
const user = await prisma.user.findUnique({
where: { steamId },
select: {
steamId: true,
name: true,
avatar: 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,
steamId: true, name: true, avatar: true, premierRank: true,
status: true, lastActiveAt: true,
vacBanned: true, numberOfVACBans: true, numberOfGameBans: true,
daysSinceLastBan: true, communityBanned: true, economyBan: true, lastBanCheck: true,
},
})
if (!user) return notFound()
return (
<Card maxWidth="auto">
<div className="max-w-5xl mx-auto py-8 px-4 space-y-6">
<ProfileHeader user={user} />
<div className="pt-4">{children}</div>
<Card
maxWidth="auto"
height="100%"
bodyScrollable={false} // ⬅️ Card selbst scrollt NICHT
className="h-full"
>
{/* Inneres Layout: Header fix, nur Content scrollt */}
<div className="max-w-6xl mx-auto h-full min-h-0 flex flex-col">
{/* fester Header */}
<div className="shrink-0 border-b border-white/10 dark:border-white/10">
<div className="py-4">
<ProfileHeader user={user} />
</div>
</div>
{/* scrollender Content */}
<div className="flex-1 min-h-0 overflow-auto overscroll-contain py-6 px-3 space-y-6">
{children}
</div>
</div>
</Card>
)

View File

@ -1,25 +1,42 @@
// app/[locale]/settings/_sections/AccountSection.tsx
import AuthCodeSettings from "../../components/settings/account/AuthCodeSettings";
import LatestKnownCodeSettings from "../../components/settings/account/ShareCodeSettings";
import { useTranslations } from 'next-intl'
import { prisma } from '@/lib/prisma'
import { getServerSession } from 'next-auth'
import { getTranslations } from 'next-intl/server'
import AuthCodeSettings from '../../components/settings/account/AuthCodeSettings'
import LatestKnownCodeSettings from '../../components/settings/account/ShareCodeSettings'
import FaceitLink from '../../components/settings/account/FaceitLink'
// import { authOptions } from '@/server/auth' // falls du AuthOptions getrennt hast
export default function AccountSection() {
const tSettings = useTranslations('settings')
export default async function AccountSection() {
const tSettings = await getTranslations('settings')
return (
<section id="account" className="scroll-mt-16 pb-10">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{tSettings("sections.account.short")}</h2>
<form className="border-t border-gray-200 dark:border-neutral-700">
{/* Auth Code Settings */}
<AuthCodeSettings />
{/* End Auth Code Settings */}
{/* Auth Code Settings */}
<LatestKnownCodeSettings />
{/* End Auth Code Settings */}
</form>
</section>
)
// Session laden (passe das an deine authOptions an)
const session = await getServerSession(/* authOptions */)
const steamId = (session as any)?.user?.id ?? null
const user = steamId
? await prisma.user.findUnique({
where: { steamId },
select: { faceitId: true, faceitNickname: true, faceitAvatar: true },
})
: null
return (
<section id="account" className="scroll-mt-16 pb-10">
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
{tSettings('sections.account.short')}
</h2>
<form className="border-t border-gray-200 dark:border-neutral-700">
<AuthCodeSettings />
<LatestKnownCodeSettings />
<FaceitLink
isLinked={!!user?.faceitId}
nickname={user?.faceitNickname ?? undefined}
avatar={user?.faceitAvatar ?? undefined}
/>
</form>
</section>
)
}

View File

@ -1,43 +1,49 @@
// /src/app/[locale]/settings/layout.tsx
'use client'
import {useRef} from 'react'
import { useTranslations } from 'next-intl'
import {useTranslations} from 'next-intl'
import ScrollSpyTabs from '../components/ScrollSpyTabs'
import Card from '../components/Card'
export default function SettingsLayoutSettings({ children }: { children: React.ReactNode }) {
const tSettings = useTranslations('settings')
const mainRef = useRef<HTMLDivElement | null>(null)
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations('settings')
const scrollRef = useRef<HTMLDivElement | null>(null)
const items = [
{ id: 'user', label: tSettings('sections.user.short') },
{ id: 'privacy', label: tSettings('sections.privacy.short') },
{ id: 'account', label: tSettings('sections.account.short') },
{ id: 'appearance', label: tSettings('sections.appearance.short') },
{ id: 'user', label: t('sections.user.short') },
{ id: 'privacy', label: t('sections.privacy.short') },
{ id: 'account', label: t('sections.account.short') },
{ id: 'appearance', label: t('sections.appearance.short') },
]
return (
<Card maxWidth='full'>
<div className="container mx-auto h-[calc(100vh-4rem)] grid gap-6 md:grid-cols-[220px_1fr] min-h-0">
{/* Sidebar */}
<aside className="min-h-0 pr-2">
<Card maxWidth="full" height="100%" bodyScrollable={false} className="h-full overflow-hidden">
{/* ⬇️ kein column-gap, wir paddn die aside selbst */}
<div className="h-full min-h-0 grid md:grid-cols-[220px_1fr] overflow-hidden">
<aside className="relative min-h-0 pr-4">
{/* ⬇️ Full-bleed Divider: kompensiert Card-Padding (p-4 / sm:p-6) */}
<span
aria-hidden
className="
pointer-events-none absolute right-0 w-px
top-[-16px] bottom-[-16px] sm:top-[-24px] sm:bottom-[-24px]
bg-black/10 dark:bg-white/12
"
/>
<div className="sticky top-0 pt-2">
<ScrollSpyTabs
items={items}
containerRef={mainRef}
containerRef={scrollRef}
className="flex flex-col gap-1"
updateHash
/>
</div>
</aside>
{/* rechter, durchgehend scrollbarer Bereich */}
<main
ref={mainRef}
className="min-h-0 h-full overflow-y-auto pr-1 md:pr-2 overscroll-contain"
>
{children}
</main>
{/* rechte Spalte scrollt */}
<div ref={scrollRef} className="min-h-0 overflow-auto overscroll-contain p-4">
<main className="min-h-0">{children}</main>
</div>
</div>
</Card>
)

View File

@ -1,5 +1,5 @@
// app/[locale]/settings/page.tsx
import Card from '../components/Card'
// /src/app/[locale]/settings/page.tsx
import AccountSection from './_sections/AccountSection'
import AppearanceSection from './_sections/AppearanceSection'
import PrivacySection from './_sections/PrivacySection'
@ -8,12 +8,10 @@ import UserSection from './_sections/UserSection'
export default function SettingsPage() {
return (
<div className="h-full min-h-0">
<Card maxWidth="full" height="100%" bodyScrollable>
<UserSection />
<PrivacySection />
<AccountSection />
<AppearanceSection />
</Card>
<UserSection />
<PrivacySection />
<AccountSection />
<AppearanceSection />
</div>
)
}

View File

@ -0,0 +1,75 @@
// /src/app/api/faceit/callback/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { pkceCookieName } from '@/server/faceit/pkce'
export async function GET(req: NextRequest) {
const search = req.nextUrl.searchParams
const code = search.get('code')
const error = search.get('error')
if (error) return NextResponse.redirect('/settings?faceit=error')
if (!code) return NextResponse.redirect('/settings?faceit=missing_code')
// PKCE verifier aus Request-Cookie lesen
const verifier = req.cookies.get(pkceCookieName())?.value || null
if (!verifier) return NextResponse.redirect('/settings?faceit=pkce_mismatch')
// Token tauschen
const tokenRes = await fetch(process.env.FACEIT_TOKEN_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.FACEIT_REDIRECT_URI!,
client_id: process.env.FACEIT_CLIENT_ID!,
code_verifier: verifier,
client_secret: process.env.FACEIT_CLIENT_SECRET || '',
}),
cache: 'no-store',
})
if (!tokenRes.ok) return NextResponse.redirect('/settings?faceit=token_error')
const token = await tokenRes.json() as {
access_token: string
refresh_token?: string
expires_in?: number
}
// Profil holen
const meRes = await fetch(`${process.env.FACEIT_API_BASE}/me`, {
headers: { Authorization: `Bearer ${token.access_token}` },
cache: 'no-store',
})
if (!meRes.ok) return NextResponse.redirect('/settings?faceit=profile_error')
const me = await meRes.json() as { guid?: string; nickname?: string; avatar?: string }
// TODO: steamId aus Session/Cookie bestimmen
const steamId = /* session?.user?.id o.ä. */ null
if (!steamId) return NextResponse.redirect('/settings?faceit=no_user')
const expires = token.expires_in ? new Date(Date.now() + token.expires_in * 1000) : null
await prisma.user.update({
where: { steamId },
data: {
faceitId: me.guid ?? null,
faceitNickname: me.nickname ?? null,
faceitAvatar: me.avatar ?? null,
faceitLinkedAt: new Date(),
// Tokens nur speichern, wenn nötig:
// faceitAccessToken: token.access_token,
// faceitRefreshToken: token.refresh_token ?? null,
// faceitTokenExpiresAt: expires,
},
})
// Redirect-Antwort erstellen und PKCE-Cookie **löschen**
const res = NextResponse.redirect('/settings?faceit=linked')
res.cookies.delete(pkceCookieName())
// (optional) state-cookie löschen
// res.cookies.delete('faceit_state')
return res
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// + Session ermitteln
export async function POST() {
const steamId = /* aus Session */ null
if (!steamId) return NextResponse.json({ ok: false }, { status: 401 })
await prisma.user.update({
where: { steamId },
data: {
faceitId: null,
faceitNickname: null,
faceitAvatar: null,
faceitLinkedAt: null,
faceitAccessToken: null,
faceitRefreshToken: null,
faceitTokenExpiresAt: null,
},
})
return NextResponse.json({ ok: true })
}

View File

@ -0,0 +1,28 @@
// /src/app/api/faceit/login/route.ts
import { NextResponse } from 'next/server'
import { createPkce, pkceCookieName, pkceCookieOptions } from '@/server/faceit/pkce'
export async function GET() {
const auth = process.env.FACEIT_AUTH_URL!
const clientId = process.env.FACEIT_CLIENT_ID!
const redirectUri = process.env.FACEIT_REDIRECT_URI!
const scopes = process.env.FACEIT_SCOPES || 'openid profile email'
const { verifier, challenge } = createPkce()
const state = crypto.randomUUID()
const url = new URL(auth)
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', scopes)
url.searchParams.set('code_challenge', challenge)
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', state)
const res = NextResponse.redirect(url.toString(), { status: 302 })
res.cookies.set(pkceCookieName(), verifier, pkceCookieOptions())
// (optional) state auch als Cookie setzen
// res.cookies.set('faceit_state', state, { ...pkceCookieOptions(), maxAge: 600 })
return res
}

File diff suppressed because one or more lines are too long

View File

@ -20,12 +20,12 @@ exports.Prisma = Prisma
exports.$Enums = {}
/**
* Prisma Client JS version: 6.16.2
* Query Engine version: 1c57fdcd7e44b29b9313256c76699e91c3ac3c43
* Prisma Client JS version: 6.16.3
* Query Engine version: bb420e667c1820a8c05a38023385f6cc7ef8e83a
*/
Prisma.prismaVersion = {
client: "6.16.2",
engine: "1c57fdcd7e44b29b9313256c76699e91c3ac3c43"
client: "6.16.3",
engine: "bb420e667c1820a8c05a38023385f6cc7ef8e83a"
}
Prisma.PrismaClientKnownRequestError = () => {
@ -143,7 +143,14 @@ exports.Prisma.UserScalarFieldEnum = {
daysSinceLastBan: 'daysSinceLastBan',
communityBanned: 'communityBanned',
economyBan: 'economyBan',
lastBanCheck: 'lastBanCheck'
lastBanCheck: 'lastBanCheck',
faceitId: 'faceitId',
faceitNickname: 'faceitNickname',
faceitAvatar: 'faceitAvatar',
faceitLinkedAt: 'faceitLinkedAt',
faceitAccessToken: 'faceitAccessToken',
faceitRefreshToken: 'faceitRefreshToken',
faceitTokenExpiresAt: 'faceitTokenExpiresAt'
};
exports.Prisma.TeamScalarFieldEnum = {

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-7fd3bf1888cc493bb4f7fc166cd70e557d17a07af04deed090c7caa1c7b0104f",
"name": "prisma-client-b999d125698de2af5f56011583f17eaff97910cd6225ae9d3f1438a1592b5997",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",
@ -151,7 +151,7 @@
},
"./*": "./*"
},
"version": "6.16.2",
"version": "6.16.3",
"sideEffects": false,
"imports": {
"#wasm-engine-loader": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -67,6 +67,17 @@ model User {
economyBan String?
lastBanCheck DateTime?
// FaceIt Account
faceitId String? @unique
faceitNickname String?
faceitAvatar String?
faceitLinkedAt DateTime?
// Falls du Tokens speichern willst (siehe Security-Hinweise unten)
faceitAccessToken String?
faceitRefreshToken String?
faceitTokenExpiresAt DateTime?
@@index([vacBanned])
@@index([numberOfVACBans])
@@index([numberOfGameBans])

File diff suppressed because one or more lines are too long

26
src/server/faceit/pkce.ts Normal file
View File

@ -0,0 +1,26 @@
// /src/server/faceit/pkce.ts
import crypto from 'crypto'
const enc = (buf: Buffer) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
export function createPkce() {
const verifier = enc(crypto.randomBytes(32))
const challenge = enc(crypto.createHash('sha256').update(verifier).digest())
return { verifier, challenge }
}
export function pkceCookieName() {
return process.env.FACEIT_COOKIE_NAME || 'faceit_pkce'
}
// Optionen zentral, damit Login & Callback identisch sind
export function pkceCookieOptions() {
return {
httpOnly: true as const,
secure: true as const,
sameSite: 'lax' as const,
path: '/',
maxAge: 10 * 60, // 10min
}
}