updated profile
This commit is contained in:
parent
bacf848455
commit
16ce72b1a6
12
.env
12
.env
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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>
|
||||
|
||||
47
src/app/[locale]/components/Pill.tsx
Normal file
47
src/app/[locale]/components/Pill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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}
|
||||
<span className="text-white/60">D</span> {m.deaths}
|
||||
{Number.isFinite(m.assists) && (
|
||||
<>
|
||||
<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 {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]"> </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>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// AuthCodeSettings.tsx
|
||||
// /src/app/[locale]/components/settings/account/AuthCodeSettings.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
|
||||
71
src/app/[locale]/components/settings/account/FaceitLink.tsx
Normal file
71
src/app/[locale]/components/settings/account/FaceitLink.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 padd’n 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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
75
src/app/api/faceit/callback/route.ts
Normal file
75
src/app/api/faceit/callback/route.ts
Normal 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
|
||||
}
|
||||
23
src/app/api/faceit/disconnect/route.ts
Normal file
23
src/app/api/faceit/disconnect/route.ts
Normal 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 })
|
||||
}
|
||||
28
src/app/api/faceit/login/route.ts
Normal file
28
src/app/api/faceit/login/route.ts
Normal 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
@ -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 = {
|
||||
|
||||
640
src/generated/prisma/index.d.ts
vendored
640
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -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": {
|
||||
|
||||
Binary file not shown.
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp14264
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp14264
Normal file
Binary file not shown.
Binary file not shown.
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
4
src/generated/prisma/runtime/react-native.js
vendored
4
src/generated/prisma/runtime/react-native.js
vendored
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
@ -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
26
src/server/faceit/pkce.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user