updates
This commit is contained in:
parent
25374ef2c0
commit
5100844e77
2
.env
2
.env
@ -13,7 +13,7 @@ NEXTAUTH_URL=https://ironieopen.local
|
||||
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
||||
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
|
||||
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
|
||||
PTERODACTYL_CLIENT_API=ptlc_6NXqjxieIekaULga2jmuTPyPwdziigT82PRbrg3G4S7
|
||||
PTERODACTYL_CLIENT_API=ptlc_c31BKDEXy63fHUxeQDahk6eeC3CL19TpG2rgao7mUl5
|
||||
PTERODACTYL_PANEL_URL=https://panel.ironieopen.de
|
||||
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
|
||||
PTERO_SERVER_SFTP_USER=army.37a11489
|
||||
|
||||
@ -70,7 +70,7 @@ export default function GameBanner(props: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const setBannerPx = useUiChromeStore(s => s.setGameBannerPx)
|
||||
const isSmDown = useIsSmDown()
|
||||
const t = useTranslations('game-banner')
|
||||
const tGameBanner = useTranslations('game-banner')
|
||||
|
||||
const phaseStr = String(phase ?? 'unknown').toLowerCase()
|
||||
const show = !isSmDown && visible && phaseStr !== 'unknown'
|
||||
@ -118,7 +118,7 @@ export default function GameBanner(props: Props) {
|
||||
<span>Map: <span className="font-semibold">{pretty.map}</span></span>
|
||||
<span>Phase: <span className="font-semibold">{pretty.phase}</span></span>
|
||||
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
|
||||
<span>{t('player-connected')}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
|
||||
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -170,7 +170,7 @@ export default function GameBanner(props: Props) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
|
||||
{isConnected ? (serverLabel ?? 'CS2 Server') : t('not-connected')}
|
||||
{isConnected ? (serverLabel ?? 'CS2 Server') : tGameBanner('not-connected')}
|
||||
</span>
|
||||
</div>
|
||||
<InfoRow />
|
||||
@ -204,7 +204,7 @@ export default function GameBanner(props: Props) {
|
||||
{isConnected ? (
|
||||
<Button color="green" variant="solid" size="md" onClick={openGame} title="Spiel öffnen" />
|
||||
) : (
|
||||
<Button color="green" variant="solid" size="md" onClick={onReconnect} title={t('reconnect')} />
|
||||
<Button color="green" variant="solid" size="md" onClick={onReconnect} title={tGameBanner('reconnect')} />
|
||||
)}
|
||||
|
||||
{isConnected && (
|
||||
@ -214,13 +214,13 @@ export default function GameBanner(props: Props) {
|
||||
size="md"
|
||||
className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none"
|
||||
onClick={() => onDisconnect?.()}
|
||||
aria-label={t('disconnected')}
|
||||
aria-label={tGameBanner('quit')}
|
||||
title={undefined}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5 block" aria-hidden="true">
|
||||
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="mt-0.5 text-[11px] font-medium opacity-90">{t('quit')}</span>
|
||||
<span className="mt-0.5 text-[11px] font-medium opacity-90">{tGameBanner('quit')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -620,7 +620,7 @@ export default function MapVotePanel({ match }: Props) {
|
||||
{/* Linke Spalte (immer dein Team) */}
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
|
||||
'flex flex-col items-start max-w-[260px] md:max-w-[400px] gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
|
||||
leftIsActiveTurn && !state?.locked
|
||||
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||
: 'bg-transparent shadow-none',
|
||||
@ -791,7 +791,7 @@ export default function MapVotePanel({ match }: Props) {
|
||||
{/* Rechte Spalte (Gegner) */}
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
|
||||
'flex flex-col items-end max-w-[260px] md:max-w-[400px] gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
|
||||
rightIsActiveTurn && !state?.locked
|
||||
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||
: 'bg-transparent shadow-none',
|
||||
|
||||
@ -20,7 +20,8 @@ type MatchRow = {
|
||||
scoreA?: number | null
|
||||
scoreB?: number | null
|
||||
score?: string | null
|
||||
team?: 'A' | 'B' | null
|
||||
team?: 'A' | 'B' | 'CT' | 'T' | null
|
||||
winnerTeam?: 'CT' | 'T' | null
|
||||
result?: 'win' | 'loss' | 'draw' | null
|
||||
matchType?: 'premier' | 'competitive' | string | null
|
||||
rankNew?: number | null
|
||||
@ -57,9 +58,6 @@ const fmtDateTime = (iso: string) =>
|
||||
|
||||
const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
|
||||
|
||||
const kdrLabel = (k?: number, d?: number) =>
|
||||
typeof k === 'number' && typeof d === 'number' ? (d === 0 ? '∞' : (k / d).toFixed(2)) : '-'
|
||||
|
||||
const parseScoreString = (raw?: string | null): [number | null, number | null] => {
|
||||
if (!raw) return [null, null]
|
||||
const [a, b] = raw.split(':').map(s => Number(s.trim()))
|
||||
@ -95,14 +93,26 @@ const inferOwnSide = (m: MatchRow, a: number | null, b: number | null): 'A' | 'B
|
||||
}
|
||||
|
||||
/** Score so anordnen, dass eigene Punkte links stehen */
|
||||
const normalizeScore = (m: MatchRow, a: number | null, b: number | null): [number | null, number | null] => {
|
||||
const normalizeScore = (
|
||||
m: MatchRow,
|
||||
a: number | null, // CT
|
||||
b: number | null, // T
|
||||
): [number | null, number | null] => {
|
||||
if (a === null || b === null) return [a, b]
|
||||
|
||||
// 0) Explizite Team-Seite aus API (CT/T)
|
||||
if (m.team === 'CT') return [a, b]
|
||||
if (m.team === 'T') return [b, a]
|
||||
|
||||
// 1) Alte A/B-Logik
|
||||
const side = inferOwnSide(m, a, b)
|
||||
if (side === 'A') return [a, b]
|
||||
if (side === 'B') return [b, a]
|
||||
return [a, b] // nicht bestimmbar → unverändert
|
||||
|
||||
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'
|
||||
@ -110,6 +120,16 @@ const computeResultFromOwn = (own: number | null, opp: number | null): 'win' | '
|
||||
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)
|
||||
: '-'
|
||||
|
||||
/* kleine Pill */
|
||||
function Pill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
@ -149,11 +169,6 @@ export default async function MatchesList({ steamId }: Props) {
|
||||
const linkId = String(m.matchId ?? m.id ?? '')
|
||||
const href = linkId ? `/match-details/${linkId}` : undefined
|
||||
|
||||
const ADR =
|
||||
isFiniteNum(m.totalDamage) && isFiniteNum(m.roundCount) && (m.roundCount ?? 0) > 0
|
||||
? ((m.totalDamage as number) / (m.roundCount as number)).toFixed(1)
|
||||
: '-'
|
||||
|
||||
const [scA, scB] = scoreOf(m)
|
||||
const [ownScore, oppScore] = normalizeScore(m, scA, scB)
|
||||
const result = m.result ?? computeResultFromOwn(ownScore, oppScore)
|
||||
@ -177,6 +192,8 @@ export default async function MatchesList({ steamId }: Props) {
|
||||
const iconSrc = iconForMap(m.map)
|
||||
const bgUrl = bgForMap(m.map)
|
||||
|
||||
console.log(m)
|
||||
|
||||
const row = (
|
||||
<div
|
||||
className={`relative cursor-pointer rounded-lg border p-3 transition ${rowTint}
|
||||
@ -232,8 +249,8 @@ export default async function MatchesList({ steamId }: Props) {
|
||||
|
||||
<Pill label="K:" value={String(m.kills)} />
|
||||
<Pill label="D:" value={String(m.deaths)} />
|
||||
<Pill label="K/D:" value={kdrLabel(m.kills, m.deaths)} />
|
||||
<Pill label="ADR:" value={ADR} />
|
||||
<Pill label="K/D:" value={kdr(m.kills, m.deaths)} />
|
||||
<Pill label="ADR:" value={adr(m.totalDamage, m.roundCount)} />
|
||||
</div>
|
||||
|
||||
{/* Rechts: Rank-Block – an den rechten Rand geschoben */}
|
||||
@ -269,7 +286,7 @@ export default async function MatchesList({ steamId }: Props) {
|
||||
return (
|
||||
<div key={`${linkId || 'row'}-${idx}`}>
|
||||
{href ? (
|
||||
<Link href={href} className="block focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg">
|
||||
<Link href={href} className="block rounded-lg">
|
||||
{row}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
// /src/app/profile/[steamId]/stats/StatsView.tsx
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Chart from '../../../Chart'
|
||||
import Card from '../../../Card'
|
||||
import { MatchStats } from '@/types/match'
|
||||
|
||||
type Props = { stats: { matches: MatchStats[] } }
|
||||
type Props = {
|
||||
steamId: string // 👈 neu: Profil-ID
|
||||
stats: { matches: MatchStats[] }
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
const fmtInt = (n: number) => new Intl.NumberFormat('de-DE').format(n)
|
||||
@ -88,12 +90,11 @@ function Pill({
|
||||
}
|
||||
|
||||
// ── component ────────────────────────────────────────────────────────────
|
||||
export default function StatsView({ stats }: Props) {
|
||||
export default function StatsView({ steamId, stats }: Props) {
|
||||
const { data: session } = useSession()
|
||||
// const steamId = session?.user?.steamId // ggf. später für Highlights etc.
|
||||
|
||||
const matches = stats.matches ?? []
|
||||
|
||||
// --- KPI-Basiswerte ---
|
||||
const totalKills = matches.reduce((sum, m) => sum + (m.kills ?? 0), 0)
|
||||
const totalDeaths = matches.reduce((sum, m) => sum + (m.deaths ?? 0), 0)
|
||||
const totalAssists = matches.reduce((sum, m) => sum + (m.assists ?? 0), 0)
|
||||
@ -105,6 +106,7 @@ export default function StatsView({ stats }: Props) {
|
||||
|
||||
const dateLabels = matches.map((m) => fmtDate(m.date))
|
||||
|
||||
// --- Lokale Aggregationen für andere Charts ---
|
||||
const killsPerMap = useMemo(() => {
|
||||
return matches.reduce<Record<string, number>>((acc, m) => {
|
||||
const k = normMapKey(m.map)
|
||||
@ -113,17 +115,34 @@ export default function StatsView({ stats }: Props) {
|
||||
}, {})
|
||||
}, [matches])
|
||||
|
||||
const gamesPerMap = useMemo(() => {
|
||||
return matches.reduce<Record<string, number>>((acc, m) => {
|
||||
const k = normMapKey(m.map)
|
||||
acc[k] = (acc[k] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
}, [matches])
|
||||
|
||||
const mapKeys = Object.keys(killsPerMap)
|
||||
const mapNames = mapKeys.map(humanizeMap)
|
||||
|
||||
// --- WINRATE je Map vom API-Endpoint laden ---
|
||||
const [wrLabels, setWrLabels] = useState<string[]>([])
|
||||
const [wrValues, setWrValues] = useState<number[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/user/${steamId}/winrate`, { cache: 'no-store' })
|
||||
if (!r.ok) throw new Error('Winrate-Laden fehlgeschlagen')
|
||||
const json: { labels: string[]; values: number[] } = await r.json()
|
||||
if (!aborted) {
|
||||
setWrLabels(json.labels || [])
|
||||
setWrValues(json.values || [])
|
||||
}
|
||||
} catch (e) {
|
||||
if (!aborted) {
|
||||
setWrLabels([])
|
||||
setWrValues([])
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { aborted = true }
|
||||
}, [steamId])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI row */}
|
||||
@ -205,9 +224,9 @@ export default function StatsView({ stats }: Props) {
|
||||
labels={['Kills', 'Assists', 'Deaths']}
|
||||
datasets={[
|
||||
{
|
||||
label: 'Anteile',
|
||||
data: [totalKills, totalAssists, totalDeaths],
|
||||
backgroundColor: [tone.blue, tone.amber, tone.red],
|
||||
label: 'Anteile',
|
||||
data: [totalKills, totalAssists, totalDeaths],
|
||||
backgroundColor: [tone.blue, tone.amber, tone.red],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@ -239,7 +258,11 @@ export default function StatsView({ stats }: Props) {
|
||||
datasets={[
|
||||
{
|
||||
label: 'K/D',
|
||||
data: matches.map((m) => kd(m.kills, m.deaths) === Infinity ? (m.kills ?? 0) : (m.kills ?? 0) / Math.max(1, m.deaths ?? 0)),
|
||||
data: matches.map((m) =>
|
||||
kd(m.kills, m.deaths) === Infinity
|
||||
? (m.kills ?? 0)
|
||||
: (m.kills ?? 0) / Math.max(1, m.deaths ?? 0)
|
||||
),
|
||||
borderColor: tone.red,
|
||||
backgroundColor: tone.redBg,
|
||||
borderWidth: 2,
|
||||
@ -333,15 +356,16 @@ export default function StatsView({ stats }: Props) {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 👇 Radar: Winrate je Map (ersetzt „Matches pro Map“) */}
|
||||
<Card>
|
||||
<Chart
|
||||
type="radar"
|
||||
title="Matches pro Map"
|
||||
labels={mapNames}
|
||||
title="Winrate je Map (%)"
|
||||
labels={wrLabels}
|
||||
datasets={[
|
||||
{
|
||||
label: 'Matches',
|
||||
data: mapKeys.map((k) => gamesPerMap[k]),
|
||||
label: 'Winrate',
|
||||
data: wrValues, // Prozentwerte 0..100
|
||||
backgroundColor: tone.blueBg,
|
||||
borderColor: tone.blue,
|
||||
borderWidth: 2,
|
||||
|
||||
@ -85,49 +85,74 @@ export default function PrivacySettings() {
|
||||
|
||||
return (
|
||||
<div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700">
|
||||
<div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-start">
|
||||
{/* Zeile: alles vertikal mittig */}
|
||||
<div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-center">
|
||||
{/* Label-Spalte */}
|
||||
<div className="sm:col-span-4 2xl:col-span-2">
|
||||
<span className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
|
||||
<div className="sm:col-span-4 2xl:col-span-2 flex items-center">
|
||||
<span className="inline-block text-sm text-gray-500 dark:text-neutral-500">
|
||||
{tSettings('sections.privacy.invites.label')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Inhalt-Spalte */}
|
||||
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
||||
{/* Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || saving}
|
||||
onClick={() => setCanBeInvited(v => !v)}
|
||||
className={[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition',
|
||||
canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed'
|
||||
].join(' ')}
|
||||
aria-pressed={canBeInvited}
|
||||
aria-label={tSettings('sections.privacy.invites.label')}
|
||||
title={undefined}
|
||||
>
|
||||
<span
|
||||
{/* Switch + Hilfstext rechts → vertikal mittig */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || saving}
|
||||
onClick={() => setCanBeInvited(v => !v)}
|
||||
className={[
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white transition',
|
||||
canBeInvited ? 'translate-x-5' : 'translate-x-1',
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition',
|
||||
canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed'
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
aria-pressed={canBeInvited}
|
||||
aria-label={tSettings('sections.privacy.invites.label')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white transition',
|
||||
canBeInvited ? 'translate-x-5' : 'translate-x-1',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-neutral-400">
|
||||
{tSettings('sections.privacy.invites.help')}
|
||||
</p>
|
||||
{/* Rechts: Hilfs-Text + Status NEBENeinander */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Hilfstext links, darf umbrechen */}
|
||||
<p className="m-0 text-sm text-gray-500 dark:text-neutral-400 min-w-0 flex-1">
|
||||
{tSettings('sections.privacy.invites.help')}
|
||||
</p>
|
||||
|
||||
{/* Status/Fehler */}
|
||||
<div className="mt-1 text-xs min-h-[1rem]">
|
||||
{loading && <span className="text-gray-500 dark:text-neutral-400">{tCommon('loading') ?? 'Laden…'}</span>}
|
||||
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving') ?? 'Speichern…'}</span>}
|
||||
{savedOk === true && <span className="text-teal-600">✓ {tCommon('saved') ?? 'Gespeichert'}</span>}
|
||||
{savedOk === false && <span className="text-red-600">{tCommon('save-failed') ?? 'Speichern fehlgeschlagen'}</span>}
|
||||
{errorMsg && <p className="text-red-600">{errorMsg}</p>}
|
||||
{/* Status rechts daneben, bleibt in einer Zeile */}
|
||||
<div className="ml-auto text-xs whitespace-nowrap" aria-live="polite">
|
||||
{loading && (
|
||||
<span className="text-gray-500 dark:text-neutral-400">
|
||||
{tCommon('loading') ?? 'Laden…'}
|
||||
</span>
|
||||
)}
|
||||
{saving && (
|
||||
<span className="text-gray-500 dark:text-neutral-400">
|
||||
{tCommon('saving') ?? 'Speichern…'}
|
||||
</span>
|
||||
)}
|
||||
{savedOk === true && (
|
||||
<span className="text-teal-600">
|
||||
✓ {tCommon('saved') ?? 'Gespeichert'}
|
||||
</span>
|
||||
)}
|
||||
{savedOk === false && (
|
||||
<span className="text-red-600">
|
||||
{tCommon('save-failed') ?? 'Speichern fehlgeschlagen'}
|
||||
</span>
|
||||
)}
|
||||
{errorMsg && <span className="text-red-600">{errorMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
// /src/app/profile/[steamId]/stats/page.tsx
|
||||
import StatsView from '@/app/[locale]/components/profile/[steamId]/stats/StatsView'
|
||||
import { MatchStats } from '@/types/match'
|
||||
|
||||
async function getStats(steamId: string) {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/api/stats/${steamId}`, { cache: 'no-store' })
|
||||
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
|
||||
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store' })
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
@ -11,5 +11,10 @@ async function getStats(steamId: string) {
|
||||
export default async function StatsPage({ params }: { params: { steamId: string } }) {
|
||||
const data = await getStats(params.steamId)
|
||||
if (!data) return <p>Keine Statistiken verfügbar.</p>
|
||||
return <StatsView stats={{ matches: data.stats as MatchStats[] }} />
|
||||
return (
|
||||
<StatsView
|
||||
steamId={params.steamId}
|
||||
stats={{ matches: data.stats as MatchStats[] }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -10,8 +10,8 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
function buildPanelUrl(base: string, serverId: string) {
|
||||
const u = new URL(base.includes('://') ? base : `https://${base}`)
|
||||
// /api/client/servers/:id/command
|
||||
const cleaned = (u.pathname || '').replace(/\/+$/,'')
|
||||
// Client-API Endpoint
|
||||
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
|
||||
return u.toString()
|
||||
}
|
||||
@ -22,41 +22,31 @@ export async function POST(req: NextRequest) {
|
||||
if (!me?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
if (!me?.isAdmin) return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
|
||||
// Body lesen
|
||||
let body: { command?: string; serverId?: string } = {}
|
||||
try { body = await req.json() } catch {}
|
||||
const command = (body.command ?? '').trim()
|
||||
if (!command) return NextResponse.json({ error: 'command required' }, { status: 400 })
|
||||
|
||||
if (!command) {
|
||||
return NextResponse.json({ error: 'command required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Panel-Base-URL aus ENV
|
||||
// Panel-URL aus ENV (wie in mapvote)
|
||||
const panelBase =
|
||||
process.env.PTERODACTYL_PANEL_URL ||
|
||||
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
|
||||
''
|
||||
(process.env.PTERODACTYL_PANEL_URL ?? process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ?? '').trim()
|
||||
if (!panelBase) {
|
||||
return NextResponse.json({ error: 'PTERODACTYL_PANEL_URL not set' }, { status: 500 })
|
||||
}
|
||||
|
||||
// ServerId (global aus Config, optional via Body überschreibbar)
|
||||
// Server-ID aus DB (optional via Body überschreibbar)
|
||||
const cfg = await prisma.serverConfig.findUnique({
|
||||
where: { id: 'default' },
|
||||
select: { pterodactylServerId: true },
|
||||
})
|
||||
const serverId = (body.serverId ?? cfg?.pterodactylServerId ?? '').trim()
|
||||
if (!serverId) {
|
||||
return NextResponse.json({ error: 'serverId not configured' }, { status: 503 })
|
||||
}
|
||||
if (!serverId) return NextResponse.json({ error: 'serverId not configured' }, { status: 503 })
|
||||
|
||||
// Userbasierter Client-API-Key
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { steamId: me.steamId! },
|
||||
select: { pterodactylClientApiKey: true },
|
||||
})
|
||||
const clientKey = (user?.pterodactylClientApiKey ?? '').trim()
|
||||
// ✅ Globaler Client-API-Key aus ENV (wie in mapvote)
|
||||
const clientKey = (process.env.PTERODACTYL_CLIENT_API ?? '').trim()
|
||||
if (!clientKey) {
|
||||
return NextResponse.json({ error: 'missing client api key for user' }, { status: 403 })
|
||||
return NextResponse.json({ error: 'PTERODACTYL_CLIENT_API not set' }, { status: 500 })
|
||||
}
|
||||
|
||||
const url = buildPanelUrl(panelBase, serverId)
|
||||
@ -70,28 +60,38 @@ export async function POST(req: NextRequest) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ command }),
|
||||
// Panel ist „extern“ – niemals cachen
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
// Pterodactyl antwortet häufig mit 204 No Content
|
||||
// Pterodactyl antwortet bei Erfolg häufig mit 204 No Content
|
||||
if (res.status === 204) {
|
||||
return NextResponse.json({ ok: true, status: 204 }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
|
||||
const text = await res.text().catch(() => '')
|
||||
let payload: any
|
||||
try { payload = JSON.parse(text) } catch { payload = text }
|
||||
|
||||
if (res.status === 401) {
|
||||
return NextResponse.json({
|
||||
error: 'pterodactyl_unauthenticated',
|
||||
detail: payload,
|
||||
hint: 'Prüfe PTERODACTYL_CLIENT_API und dass es ein gültiger CLIENT-API-Key ist.',
|
||||
}, { status: 502 })
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return NextResponse.json({
|
||||
error: 'pterodactyl_forbidden',
|
||||
detail: payload,
|
||||
hint: 'Der Key ist gültig, hat aber keine Rechte auf diesen Server (Owner/Subuser + Console).',
|
||||
}, { status: 502 })
|
||||
}
|
||||
if (!res.ok) {
|
||||
let errPayload: any = undefined
|
||||
try { errPayload = JSON.parse(text) } catch {}
|
||||
return NextResponse.json(
|
||||
{ error: 'pterodactyl_error', status: res.status, body: errPayload ?? text },
|
||||
{ status: 502 }
|
||||
)
|
||||
return NextResponse.json({ error: 'pterodactyl_error', status: res.status, body: payload }, { status: 502 })
|
||||
}
|
||||
|
||||
let json: any = {}
|
||||
try { json = JSON.parse(text) } catch { json = { body: text } }
|
||||
return NextResponse.json({ ok: true, status: res.status, response: json }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
// In seltenen Fällen kommt JSON zurück
|
||||
return NextResponse.json({ ok: true, status: res.status, response: payload }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e?.message ?? 'request_failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
@ -23,12 +23,11 @@ const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
|
||||
const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
|
||||
|
||||
async function unloadCurrentMatch() {
|
||||
// einige MatchZy Builds nutzen "matchzy_unloadmatch",
|
||||
// andere trennen zwischen cancel/end. Der Unload reicht meist.
|
||||
await sendServerCommand('matchzy_unloadmatch')
|
||||
// optional „end/cancel“ hinterher, falls dein Build es erfordert:
|
||||
// await sendServerCommand('matchzy_cancelmatch')
|
||||
await sleep(500) // Server eine halbe Sekunde Luft lassen
|
||||
// Statt "matchzy_unloadmatch": Plugin hard neustarten
|
||||
await sendServerCommand(`css_plugins stop "MatchZy"`)
|
||||
await sleep(800) // kleinen Moment warten, bis das Plugin sauber entladen ist
|
||||
await sendServerCommand(`css_plugins start "MatchZy"`)
|
||||
await sleep(1200) // Plugin initialisieren lassen (je nach Server ggf. erhöhen)
|
||||
}
|
||||
|
||||
function makeRandomMatchId() {
|
||||
@ -89,6 +88,8 @@ async function sendServerCommand(command: string) {
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
console.log(res);
|
||||
|
||||
if (res.status === 204) {
|
||||
console.log('[mapvote] Command OK (204):', command)
|
||||
return
|
||||
|
||||
@ -41,7 +41,6 @@ export async function GET(
|
||||
teamAUsers: { select: { steamId: true } },
|
||||
teamBUsers: { select: { steamId: true } },
|
||||
winnerTeam: true,
|
||||
// nur die Stats des angefragten Spielers
|
||||
players: { where: { steamId }, select: { stats: true } },
|
||||
},
|
||||
})
|
||||
@ -49,17 +48,19 @@ export async function GET(
|
||||
const hasMore = matches.length > limit
|
||||
const page = hasMore ? matches.slice(0, limit) : matches
|
||||
|
||||
|
||||
const items = page.map(m => {
|
||||
const stats = m.players[0]?.stats ?? null
|
||||
|
||||
const kills = stats?.kills ?? 0
|
||||
const deaths = stats?.deaths ?? 0
|
||||
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
|
||||
|
||||
const rankOld = stats?.rankOld ?? null
|
||||
const rankNew = stats?.rankNew ?? null
|
||||
const rankChange = rankNew != null && rankOld != null ? rankNew - rankOld : null
|
||||
const aim = stats?.aim ?? null
|
||||
const kills = stats?.kills ?? 0
|
||||
const deaths = stats?.deaths ?? 0
|
||||
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
|
||||
const rankOld = stats?.rankOld ?? null
|
||||
const rankNew = stats?.rankNew ?? null
|
||||
const rankChange = rankNew != null && rankOld != null ? rankNew - rankOld : null
|
||||
const aim = stats?.aim ?? null
|
||||
const totalDamage = stats?.totalDamage ?? null // <- HINZU
|
||||
const assists = stats?.assists ?? null // <- optional
|
||||
|
||||
const playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
|
||||
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
|
||||
@ -78,6 +79,8 @@ export async function GET(
|
||||
deaths,
|
||||
kdr,
|
||||
aim,
|
||||
totalDamage,
|
||||
assists,
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
team : playerTeam,
|
||||
}
|
||||
|
||||
142
src/app/api/user/[steamId]/winrate/route.ts
Normal file
142
src/app/api/user/[steamId]/winrate/route.ts
Normal file
@ -0,0 +1,142 @@
|
||||
// /src/app/api/user/[steamId]/winrate/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||
|
||||
/** Map-Key normalisieren (z.B. "maps/de_inferno.bsp" -> "de_inferno") */
|
||||
function normMapKey(raw?: string | null) {
|
||||
return (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
||||
}
|
||||
|
||||
// Label- und Ordnungs-Lookup aus MAP_OPTIONS aufbauen
|
||||
const MAP_LABEL_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.label] as const))
|
||||
const MAP_ACTIVE_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.active] as const))
|
||||
const MAP_ORDER_BY_KEY = new Map(
|
||||
MAP_OPTIONS.map((o, idx) => [o.key, idx] as const) // Reihenfolge wie in mapOptions
|
||||
)
|
||||
|
||||
// Optional: bestimmte Pseudo-„Maps“ ignorieren (Lobby, etc.)
|
||||
const IGNORED_KEYS = new Set(['lobby_mapvote'])
|
||||
|
||||
function labelFor(key: string) {
|
||||
return MAP_LABEL_BY_KEY.get(key)
|
||||
?? key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
/** Gewinner-Seite ermitteln */
|
||||
function computeWinnerSide(m: {
|
||||
winnerTeam: string | null
|
||||
teamAId: string | null
|
||||
teamBId: string | null
|
||||
scoreA: number | null
|
||||
scoreB: number | null
|
||||
}): 'A' | 'B' | null {
|
||||
const w = (m.winnerTeam ?? '').trim().toLowerCase()
|
||||
if (w) {
|
||||
if (w === 'a' || w === (m.teamAId ?? '').toLowerCase()) return 'A'
|
||||
if (w === 'b' || w === (m.teamBId ?? '').toLowerCase()) return 'B'
|
||||
}
|
||||
if (typeof m.scoreA === 'number' && typeof m.scoreB === 'number') {
|
||||
if (m.scoreA > m.scoreB) return 'A'
|
||||
if (m.scoreB > m.scoreA) return 'B'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/user/:steamId/winrate?types=premier,competitive&onlyActive=true
|
||||
*
|
||||
* Antwort:
|
||||
* {
|
||||
* labels: string[] // z.B. ["Inferno", "Mirage", ...] – aus MAP_OPTIONS
|
||||
* keys: string[] // z.B. ["de_inferno", "de_mirage", ...]
|
||||
* values: number[] // Winrate 0..100 (ein Nachkomma)
|
||||
* byMap: Record<key, { wins, losses, total, pct }>
|
||||
* }
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: { steamId: string } }) {
|
||||
const steamId = params.steamId
|
||||
if (!steamId) return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const typesParam = searchParams.get('types')
|
||||
const types = typesParam ? typesParam.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
const onlyActive = (searchParams.get('onlyActive') ?? 'true').toLowerCase() !== 'false'
|
||||
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
players: { some: { steamId } },
|
||||
...(types.length ? { matchType: { in: types } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
map: true,
|
||||
scoreA: true,
|
||||
scoreB: true,
|
||||
teamAId: true,
|
||||
teamBId: true,
|
||||
winnerTeam: true,
|
||||
teamAUsers: { select: { steamId: true } },
|
||||
teamBUsers: { select: { steamId: true } },
|
||||
},
|
||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
||||
take: 1000,
|
||||
})
|
||||
|
||||
const byMap: Record<string, { wins: number; losses: number; total: number; pct: number }> = {}
|
||||
|
||||
for (const m of matches) {
|
||||
const keyRaw = normMapKey(m.map) || 'unknown'
|
||||
if (IGNORED_KEYS.has(keyRaw)) continue
|
||||
if (onlyActive && MAP_ACTIVE_BY_KEY.has(keyRaw) && !MAP_ACTIVE_BY_KEY.get(keyRaw)) continue
|
||||
|
||||
const key = keyRaw
|
||||
if (!byMap[key]) byMap[key] = { wins: 0, losses: 0, total: 0, pct: 0 }
|
||||
|
||||
const inA = m.teamAUsers.some(u => u.steamId === steamId)
|
||||
const inB = !inA && m.teamBUsers.some(u => u.steamId === steamId)
|
||||
if (!inA && !inB) continue
|
||||
|
||||
const winner = computeWinnerSide({
|
||||
winnerTeam: m.winnerTeam ?? null,
|
||||
teamAId: m.teamAId ?? null,
|
||||
teamBId: m.teamBId ?? null,
|
||||
scoreA: m.scoreA ?? null,
|
||||
scoreB: m.scoreB ?? null,
|
||||
})
|
||||
|
||||
byMap[key].total += 1
|
||||
if (winner) {
|
||||
if ((winner === 'A' && inA) || (winner === 'B' && inB)) byMap[key].wins += 1
|
||||
else byMap[key].losses += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Prozente berechnen
|
||||
const presentKeys = Object.keys(byMap)
|
||||
for (const k of presentKeys) {
|
||||
const it = byMap[k]
|
||||
it.pct = it.total > 0 ? Math.round((it.wins / it.total) * 1000) / 10 : 0 // 1 Nachkomma
|
||||
}
|
||||
|
||||
// Sortierung: 1) Reihenfolge aus MAP_OPTIONS, 2) danach alphabetisch (Label)
|
||||
const sortedKeys = presentKeys.sort((a, b) => {
|
||||
const ia = MAP_ORDER_BY_KEY.has(a) ? (MAP_ORDER_BY_KEY.get(a) as number) : Number.POSITIVE_INFINITY
|
||||
const ib = MAP_ORDER_BY_KEY.has(b) ? (MAP_ORDER_BY_KEY.get(b) as number) : Number.POSITIVE_INFINITY
|
||||
if (ia !== ib) return ia - ib
|
||||
return labelFor(a).localeCompare(labelFor(b), 'de', { sensitivity: 'base' })
|
||||
})
|
||||
|
||||
const labels = sortedKeys.map(k => labelFor(k))
|
||||
const values = sortedKeys.map(k => byMap[k].pct)
|
||||
|
||||
return NextResponse.json(
|
||||
{ labels, keys: sortedKeys, values, byMap },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[winrate] Fehler:', err)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
128
src/lib/stats/winrate.ts
Normal file
128
src/lib/stats/winrate.ts
Normal file
@ -0,0 +1,128 @@
|
||||
// /src/lib/stats/winrate.ts
|
||||
import 'server-only'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type WinBucket = {
|
||||
wins: number
|
||||
losses: number
|
||||
draws: number
|
||||
winrate: number // 0..1
|
||||
}
|
||||
|
||||
export type WinrateSummary = {
|
||||
total: WinBucket
|
||||
byMap: Record<string, WinBucket>
|
||||
sampleSize: number // anzahl gewerteter matches (ohne fehlende scores)
|
||||
}
|
||||
|
||||
export type WinrateOptions = {
|
||||
types?: string[] // z.B. ['premier', 'competitive']
|
||||
from?: Date | null // ab datum (demoDate)
|
||||
to?: Date | null // bis datum (demoDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Robust ermitteln, ob der User Match gewonnen hat.
|
||||
* Nutzt bevorzugt scoreA/scoreB; fällt sonst (best effort) auf winnerTeam zurück.
|
||||
*/
|
||||
function resultForUser(match: {
|
||||
scoreA: number | null
|
||||
scoreB: number | null
|
||||
winnerTeam: string | null
|
||||
teamAUsers: { steamId: string }[]
|
||||
teamBUsers: { steamId: string }[]
|
||||
teamAId?: string | null
|
||||
teamBId?: string | null
|
||||
}, steamId: string): 'win' | 'loss' | 'draw' | 'unknown' {
|
||||
const inA = match.teamAUsers.some(u => u.steamId === steamId)
|
||||
const inB = match.teamBUsers.some(u => u.steamId === steamId)
|
||||
|
||||
// Falls Scores vorhanden sind → daran entscheiden
|
||||
if (match.scoreA != null && match.scoreB != null) {
|
||||
if (match.scoreA === match.scoreB) return 'draw'
|
||||
if (inA && match.scoreA > match.scoreB) return 'win'
|
||||
if (inB && match.scoreB > match.scoreA) return 'win'
|
||||
if (inA || inB) return 'loss'
|
||||
}
|
||||
|
||||
// Fallback: winnerTeam (kann 'A'/'B', teamId oder teamName sein – wir behandeln A/B/Id)
|
||||
if (match.winnerTeam) {
|
||||
const wt = String(match.winnerTeam).toLowerCase()
|
||||
const aIds = new Set([String(match.teamAId ?? ''), 'a'])
|
||||
const bIds = new Set([String(match.teamBId ?? ''), 'b'])
|
||||
if (inA && aIds.has(wt)) return 'win'
|
||||
if (inB && bIds.has(wt)) return 'win'
|
||||
if (inA || inB) return 'loss'
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function newBucket(): WinBucket {
|
||||
return { wins: 0, losses: 0, draws: 0, winrate: 0 }
|
||||
}
|
||||
|
||||
function finalizeBucket(b: WinBucket) {
|
||||
const played = b.wins + b.losses // Draws werden meist nicht in WR eingerechnet
|
||||
b.winrate = played > 0 ? b.wins / played : 0
|
||||
return b
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregiert Winrates (gesamt & je Map) für einen User.
|
||||
* Keine Schema-Änderung nötig – basiert auf Match.map + Score + Team-Zugehörigkeit.
|
||||
*/
|
||||
export async function getUserWinrates(
|
||||
steamId: string,
|
||||
opts: WinrateOptions = {}
|
||||
): Promise<WinrateSummary> {
|
||||
const { types, from, to } = opts
|
||||
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
players: { some: { steamId } }, // hat mitgespielt
|
||||
...(types?.length ? { matchType: { in: types } } : {}),
|
||||
...(from ? { demoDate: { gte: from } } : {}),
|
||||
...(to ? { demoDate: { lte: to } } : {}),
|
||||
},
|
||||
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
map: true,
|
||||
scoreA: true,
|
||||
scoreB: true,
|
||||
winnerTeam: true,
|
||||
teamAId: true,
|
||||
teamBId: true,
|
||||
teamAUsers: { select: { steamId: true } },
|
||||
teamBUsers: { select: { steamId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const total = newBucket()
|
||||
const byMap: Record<string, WinBucket> = {}
|
||||
|
||||
// „gewertete“ Spiele: solche mit eindeutigem Ergebnis (Scores oder winnerTeam nutzbar)
|
||||
let sampleSize = 0
|
||||
|
||||
for (const m of matches) {
|
||||
const mapKey = (m.map ?? 'Unknown').toLowerCase()
|
||||
if (!byMap[mapKey]) byMap[mapKey] = newBucket()
|
||||
|
||||
const res = resultForUser(m, steamId)
|
||||
if (res === 'unknown') continue
|
||||
|
||||
sampleSize += 1
|
||||
const buckets = [total, byMap[mapKey]]
|
||||
for (const b of buckets) {
|
||||
if (res === 'win') b.wins += 1
|
||||
else if (res === 'loss') b.losses += 1
|
||||
else b.draws += 1
|
||||
}
|
||||
}
|
||||
|
||||
finalizeBucket(total)
|
||||
for (const k of Object.keys(byMap)) finalizeBucket(byMap[k])
|
||||
|
||||
return { total, byMap, sampleSize }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user