This commit is contained in:
Linrador 2025-07-31 17:41:54 +02:00
parent 2675c6363c
commit c8945e55d8
53 changed files with 5224 additions and 333 deletions

56
package-lock.json generated
View File

@ -16,13 +16,14 @@
"@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.10.1",
"chart.js": "^4.5.0",
"csgo-sharecode": "^3.1.2",
"datatables.net": "^2.2.2",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dropzone": "^6.0.0-beta.2",
"flag-icons": "^7.3.2",
"framer-motion": "^12.9.4",
"framer-motion": "^12.18.1",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"lzma-native": "^8.0.6",
@ -36,6 +37,7 @@
"postcss": "^8.5.3",
"preline": "^3.0.1",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"vanilla-calendar-pro": "^3.0.4",
"zustand": "^5.0.3"
@ -1319,6 +1321,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz",
@ -2966,6 +2974,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -4204,13 +4224,13 @@
}
},
"node_modules/framer-motion": {
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.9.4.tgz",
"integrity": "sha512-yaeGDmGQ3eCQEwZ95/pRQMaSh/Q4E2CK6JYOclG/PdjyQad0MULJ+JFVV8911Fl5a6tF6o0wgW8Dpl5Qx4Adjg==",
"version": "12.18.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.18.1.tgz",
"integrity": "sha512-6o4EDuRPLk4LSZ1kRnnEOurbQ86MklVk+Y1rFBUKiF+d2pCdvMjWVu0ZkyMVCTwl5UyTH2n/zJEJx+jvTYuxow==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.9.4",
"motion-utils": "^12.9.4",
"motion-dom": "^12.18.1",
"motion-utils": "^12.18.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
@ -5549,18 +5569,18 @@
}
},
"node_modules/motion-dom": {
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.9.4.tgz",
"integrity": "sha512-25TWkQPj5I18m+qVjXGtCsxboY11DaRC5HMjd29tHKExazW4Zf4XtAagBdLpyKsVuAxEQ6cx5/E4AB21PFpLnQ==",
"version": "12.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.18.1.tgz",
"integrity": "sha512-dR/4EYT23Snd+eUSLrde63Ws3oXQtJNw/krgautvTfwrN/2cHfCZMdu6CeTxVfRRWREW3Fy1f5vobRDiBb/q+w==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.9.4"
"motion-utils": "^12.18.1"
}
},
"node_modules/motion-utils": {
"version": "12.9.4",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.9.4.tgz",
"integrity": "sha512-BW3I65zeM76CMsfh3kHid9ansEJk9Qvl+K5cu4DVHKGsI52n76OJ4z2CUJUV+Mn3uEP9k1JJA3tClG0ggSrRcg==",
"version": "12.18.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.18.1.tgz",
"integrity": "sha512-az26YDU4WoDP0ueAkUtABLk2BIxe28d8NH1qWT8jPGhPyf44XTdDUh8pDk9OPphaSrR9McgpcJlgwSOIw/sfkA==",
"license": "MIT"
},
"node_modules/ms": {
@ -6334,6 +6354,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",

View File

@ -19,13 +19,14 @@
"@preline/dropdown": "^3.0.1",
"@preline/tooltip": "^3.0.0",
"@prisma/client": "^6.10.1",
"chart.js": "^4.5.0",
"csgo-sharecode": "^3.1.2",
"datatables.net": "^2.2.2",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dropzone": "^6.0.0-beta.2",
"flag-icons": "^7.3.2",
"framer-motion": "^12.9.4",
"framer-motion": "^12.18.1",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"lzma-native": "^8.0.6",
@ -39,6 +40,7 @@
"postcss": "^8.5.3",
"preline": "^3.0.1",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"vanilla-calendar-pro": "^3.0.4",
"zustand": "^5.0.3"

View File

@ -40,6 +40,9 @@ model User {
serverRequests ServerRequest[] @relation("MatchRequests")
rankHistory RankHistory[] @relation("UserRankHistory")
demoFiles DemoFile[]
createdSchedules Schedule[] @relation("CreatedSchedules")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
}
model Team {
@ -52,13 +55,16 @@ model Team {
activePlayers String[]
inactivePlayers String[]
leader User? @relation("TeamLeader", fields: [leaderId], references: [steamId])
members User[] @relation("UserTeam")
invites TeamInvite[]
leader User? @relation("TeamLeader", fields: [leaderId], references: [steamId])
members User[] @relation("UserTeam")
invites TeamInvite[]
matchPlayers MatchPlayer[]
matchesAsTeamA Match[] @relation("MatchTeamA")
matchesAsTeamB Match[] @relation("MatchTeamB")
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
}
model TeamInvite {
@ -102,10 +108,10 @@ model Match {
scoreB Int?
teamAId String?
teamA Team? @relation("MatchTeamA", fields: [teamAId], references: [id])
teamA Team? @relation("MatchTeamA", fields: [teamAId], references: [id])
teamBId String?
teamB Team? @relation("MatchTeamB", fields: [teamBId], references: [id])
teamB Team? @relation("MatchTeamB", fields: [teamBId], references: [id])
teamAUsers User[] @relation("TeamAPlayers")
teamBUsers User[] @relation("TeamBPlayers")
@ -124,6 +130,8 @@ model Match {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
schedule Schedule?
}
model MatchPlayer {
@ -144,9 +152,9 @@ model MatchPlayer {
}
model PlayerStats {
id String @id @default(uuid())
matchId String
steamId String
id String @id @default(uuid())
matchId String
steamId String
kills Int
assists Int
@ -168,8 +176,15 @@ model PlayerStats {
noScopes Int @default(0)
blindKills Int @default(0)
rankOld Int?
rankNew Int?
k1 Int @default(0)
k2 Int @default(0)
k3 Int @default(0)
k4 Int @default(0)
k5 Int @default(0)
rankOld Int?
rankNew Int?
rankChange Int?
winCount Int?
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
@ -177,7 +192,6 @@ model PlayerStats {
@@unique([matchId, steamId])
}
model RankHistory {
id String @id @default(uuid())
steamId String
@ -194,6 +208,41 @@ model RankHistory {
match Match? @relation("MatchRankHistory", fields: [matchId], references: [id])
}
model Schedule {
id String @id @default(uuid())
title String
description String?
map String?
date DateTime
status ScheduleStatus @default(PENDING)
teamAId String?
teamA Team? @relation("ScheduleTeamA", fields: [teamAId], references: [id])
teamBId String?
teamB Team? @relation("ScheduleTeamB", fields: [teamBId], references: [id])
createdById String
createdBy User @relation("CreatedSchedules", fields: [createdById], references: [steamId])
confirmedById String?
confirmedBy User? @relation("ConfirmedSchedules", fields: [confirmedById], references: [steamId])
linkedMatchId String? @unique
linkedMatch Match? @relation(fields: [linkedMatchId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum ScheduleStatus {
PENDING
CONFIRMED
DECLINED
CANCELLED
COMPLETED
}
//
// ──────────────────────────────────────────────
// 📦 Demo-Dateien & CS2 Requests

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -77,6 +77,7 @@ export async function GET(_: Request, context: { params: { id: string } }) {
description: match.description,
demoDate: match.demoDate,
matchType: match.matchType,
roundCount: match.roundCount,
map: match.map,
teamA,
teamB,

View File

@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET() {
try {
const schedules = await prisma.schedule.findMany({
orderBy: {
date: 'asc',
},
include: {
teamA: true,
teamB: true,
createdBy: {
select: {
steamId: true,
name: true,
avatar: true,
},
},
confirmedBy: {
select: {
steamId: true,
name: true,
avatar: true,
},
},
linkedMatch: {
select: {
id: true,
map: true,
scoreA: true,
scoreB: true,
demoDate: true,
},
},
},
})
return NextResponse.json({ schedules })
} catch (error) {
console.error('❌ Fehler beim Abrufen der geplanten Matches:', error)
return new NextResponse('Serverfehler beim Laden der geplanten Matches', {
status: 500,
})
}
}

View File

@ -0,0 +1,66 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET(
_req: Request,
{ params }: { params: { steamId: string } }
) {
const steamId = params.steamId
if (!steamId) {
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
}
try {
// Hole den User (nur die nötigsten Felder)
const user = await prisma.user.findUnique({
where: { steamId },
select: {
steamId: true,
name: true,
avatar: true,
},
})
if (!user) {
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 })
}
// Hole alle MatchPlayer-Datensätze inkl. zugehörigem Match
const matches = await prisma.matchPlayer.findMany({
where: { steamId },
include: {
match: {
select: {
demoDate: true,
map: true,
},
},
stats: true,
},
orderBy: {
match: {
demoDate: 'asc',
},
},
})
// Formatiere die Stats wie vom Frontend benötigt
const stats = matches.map((entry) => ({
date: entry.match?.demoDate?.toISOString().split('T')[0] ?? 'Unbekannt',
map: entry.match?.map ?? 'Unbekannt',
kills: entry.stats?.kills ?? 0,
deaths: entry.stats?.deaths ?? 0,
assists: entry.stats?.assists ?? 0,
totalDamage: entry.stats?.totalDamage ?? 0,
headshotPct: entry.stats?.headshotPct ?? 0,
rankNew: entry.stats?.rankNew ?? null,
rankChange: entry.stats?.rankChange ?? null,
}))
return NextResponse.json({ user, stats }, { status: 200 })
} catch (error) {
console.error('[API/stats] Fehler beim Laden der Daten:', error)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
}
}

View File

@ -12,6 +12,7 @@ export async function GET() {
name: true,
avatar: true,
location: true,
premierRank: true,
},
orderBy: {
name: 'asc',

View File

@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
await prisma.notification.create({
data: {
steamId: leader.steamId,
steamId: leader,
title: 'Team erstellt',
message: `Du hast erfolgreich das Team "${teamname}" erstellt.`,
},

View File

@ -1,21 +1,20 @@
// /pages/api/team/delete.ts
import { NextApiRequest, NextApiResponse } from 'next'
// /app/api/team/delete/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { teamId } = req.body
export async function POST(req: Request) {
const body = await req.json()
const { teamId } = body
if (!teamId) {
return res.status(400).json({ error: 'Team-ID fehlt' })
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
}
try {
await prisma.team.delete({ where: { id: teamId } })
return res.status(200).json({ success: true })
return NextResponse.json({ success: true })
} catch (err) {
console.error(err)
return res.status(500).json({ error: 'Team konnte nicht gelöscht werden' })
console.error('❌ Fehler beim Löschen des Teams:', err)
return NextResponse.json({ error: 'Team konnte nicht gelöscht werden' }, { status: 500 })
}
}

View File

@ -0,0 +1,91 @@
// /app/api/user/[steamId]/matches/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET(
_req: Request,
{ params }: { params: { steamId: string } }
) {
const steamId = params.steamId
if (!steamId) {
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
}
try {
const matchPlayers = await prisma.matchPlayer.findMany({
where: { steamId },
select: {
teamId: true,
team: true,
match: {
select: {
id: true,
demoDate: true,
map: true,
roundCount: true,
scoreA: true,
scoreB: true,
matchType: true,
teamAId: true,
teamBId: true,
teamAUsers: true,
teamBUsers: true,
winnerTeam: true,
},
},
stats: true,
},
orderBy: {
match: {
demoDate: 'desc',
},
},
})
const data = matchPlayers.map((mp) => {
const match = mp.match
const stats = mp.stats
const kills = stats?.kills ?? 0
const deaths = stats?.deaths ?? 0
const kdr = deaths > 0 ? (kills / deaths).toFixed(2) : '∞'
const roundCount = match.roundCount
const rankOld = stats?.rankOld ?? null
const rankNew = stats?.rankNew ?? null
const rankChange =
typeof rankNew === 'number' && typeof rankOld === 'number'
? rankNew - rankOld
: null
const matchType = match.matchType ?? 'community'
const isInTeamA = match.teamAUsers.some((user) => user.steamId === steamId)
const playerTeam = isInTeamA ? 'CT' : 'T'
const scoreCT = match.scoreA ?? 0
const scoreT = match.scoreB ?? 0
const score = `${scoreCT} : ${scoreT}`
return {
id: match.id,
map: match.map ?? 'Unknown',
date: match.demoDate,
matchType,
score,
roundCount,
rankOld,
rankNew,
rankChange,
kills,
deaths,
kdr,
winnerTeam: match.winnerTeam ?? null,
team: playerTeam,
}
})
return NextResponse.json(data)
} catch (error) {
console.error('[API] Fehler beim Laden der Matches:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@ -0,0 +1,39 @@
// /app/api/user/[steamId]/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma'
export async function GET(
_req: Request,
{ params }: { params: { steamId: string } }
) {
const steamId = params.steamId
if (!steamId) {
return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
}
try {
const user = await prisma.user.findUnique({
where: { steamId },
select: {
steamId: true,
name: true,
avatar: true,
},
})
if (!user) {
return NextResponse.json({ error: 'User nicht gefunden' }, { status: 404 })
}
// Beispielhaft auch "stats" zurückgeben, falls gewünscht
const stats = await prisma.matchPlayer.findMany({
where: { steamId },
})
return NextResponse.json({ user, stats }, { status: 200 })
} catch (error) {
console.error('[API] Fehler beim Laden des Users:', error)
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
}
}

View File

@ -1,92 +0,0 @@
// /app/api/user/matches/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/lib/auth';
import { prisma } from '@/app/lib/prisma';
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions(req));
const steamId = session?.user?.steamId;
if (!steamId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const matchPlayers = await prisma.matchPlayer.findMany({
where: { steamId: steamId },
select: {
teamId: true,
team: true,
match: {
select: {
id: true,
demoDate: true,
map: true,
scoreA: true,
scoreB: true,
matchType: true,
teamAId: true,
teamBId: true,
winnerTeam: true,
demoData: true,
},
},
stats: true,
},
orderBy: {
match: {
demoDate: 'desc',
},
},
});
const data = matchPlayers.map((mp) => {
const match = mp.match;
const stats = mp.stats;
const kills = stats?.kills ?? 0;
const deaths = stats?.deaths ?? 0;
const kd = deaths > 0 ? (kills / deaths).toFixed(2) : '∞';
const rankOld = stats?.rankOld ?? null;
const rankNew = stats?.rankNew ?? null;
const rankChange =
typeof rankNew === 'number' && typeof rankOld === 'number'
? rankNew - rankOld
: null;
const matchType = match.matchType ?? 'community';
const demoData = match.demoData as any;
// Spielerteam: CT oder T
let playerTeam: string | null = null;
let isInTeamA = false;
let isInTeamB = false;
if (demoData?.teamA?.players && demoData?.teamB?.players) {
isInTeamA = demoData.teamA.players.some((p: any) => p?.steamId === steamId);
isInTeamB = demoData.teamB.players.some((p: any) => p?.steamId === steamId);
if (isInTeamA) playerTeam = 'CT';
if (isInTeamB) playerTeam = 'T';
}
const scoreLeft = isInTeamA ? match.scoreA : match.scoreB;
const scoreRight = isInTeamB ? match.scoreB : match.scoreA;
return {
id: match.id,
map: match.map ?? 'Unknown',
date: match.demoDate,
score: `${scoreLeft ?? 0} : ${scoreRight ?? 0}`,
matchType,
rankOld,
rankNew,
rankChange,
kills,
deaths,
kd,
winnerTeam: match.winnerTeam ?? null,
team: mp.team?.name ?? null,
};
});
return NextResponse.json(data);
}

View File

@ -0,0 +1,101 @@
'use client'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
RadialLinearScale,
BarElement,
PointElement,
LineElement,
ArcElement,
Tooltip,
Legend,
Title,
} from 'chart.js'
import { Line, Bar, Radar, Doughnut, PolarArea, Bubble, Pie, Scatter } from 'react-chartjs-2'
import { useMemo } from 'react'
ChartJS.register(
CategoryScale,
LinearScale,
RadialLinearScale,
BarElement,
PointElement,
LineElement,
ArcElement,
Tooltip,
Legend,
Title
)
type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar'
type ChartProps = {
type: ChartType
labels: string[]
datasets: {
label: string
data: number[]
backgroundColor?: string | string[]
borderColor?: string
borderWidth?: number
}[]
title?: string
height?: number
hideLabels?: boolean
}
export default function Chart({
type,
labels,
datasets,
title,
height = 300,
hideLabels
}: ChartProps) {
const data = useMemo(() => ({ labels, datasets }), [labels, datasets])
const options = useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: !hideLabels,
position: 'top' as const,
},
title: {
display: !!title,
text: title,
},
},
scales: hideLabels
? {
x: { display: false },
y: { display: false },
}
: undefined,
}),
[title, hideLabels]
)
const chartMap = {
line: Line,
bar: Bar,
radar: Radar,
doughnut: Doughnut,
polararea: PolarArea,
bubble: Bubble,
pie: Pie,
scatter: Scatter,
}
const ChartComponent = chartMap[type]
return (
<div style={{ height }}>
<ChartComponent data={data} options={options} />
</div>
)
}

View File

@ -42,7 +42,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
}
setStatus('success')
setMessage(`Team "${result.team.teamname}" wurde erfolgreich erstellt!`)
setMessage(`Team "${result.team.name}" wurde erfolgreich erstellt!`)
setTeamname('')
setTimeout(() => {
@ -81,57 +81,54 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
closeButtonTitle="Team erstellen"
>
<div className="max-w-sm space-y-2">
<label htmlFor="teamname" className="block text-sm font-medium mb-1 dark:text-white">
<label htmlFor="teamname" className="block text-sm font-medium mb-2 dark:text-white">
Teamname
</label>
<div className="relative">
<input
id="teamname"
type="text"
value={teamname}
onChange={(e) => {
setTeamname(e.target.value)
setStatus('idle')
setMessage('')
}}
className={`py-2.5 px-4 block w-full rounded-lg sm:text-sm focus:ring-1
dark:bg-neutral-800 dark:text-neutral-200 dark:border-neutral-700
${
status === 'error'
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: status === 'success'
? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}
`}
required
name="teamname"
aria-describedby="teamname-feedback"
/>
<input
id="teamname"
type="text"
value={teamname}
onChange={(e) => {
setTeamname(e.target.value)
setStatus('idle')
setMessage('')
}}
className={`py-2.5 sm:py-3 px-4 block w-full border-gray-200 rounded-lg sm:text-sm focus:border-blue-500 focus:ring-blue-500 disabled:opacity-50 disabled:pointer-events-none dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600
${
status === 'error'
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: status === 'success'
? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}
`}
required
name="teamname"
aria-describedby="teamname-feedback"
/>
{status !== 'idle' && (
<div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none">
<svg
className={`shrink-0 size-4 ${status === 'error' ? 'text-red-500' : 'text-teal-500'}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
{status === 'error' ? (
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</>
) : (
<polyline points="20 6 9 17 4 12" />
)}
</svg>
</div>
)}
</div>
{status !== 'idle' && (
<div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none">
<svg
className={`shrink-0 size-4 ${status === 'error' ? 'text-red-500' : 'text-teal-500'}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
{status === 'error' ? (
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</>
) : (
<polyline points="20 6 9 17 4 12" />
)}
</svg>
</div>
)}
{message && (
<p

View File

@ -6,6 +6,8 @@ import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react'
import LoadingSpinner from './LoadingSpinner'
import { Player, Team } from '../types/team'
import Pagination from './Pagination'
import { AnimatePresence, motion } from 'framer-motion'
type Props = {
show: boolean
@ -23,6 +25,10 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false)
const [sentCount, setSentCount] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
const usersPerPage = 9
const [currentPage, setCurrentPage] = useState(1)
useEffect(() => {
if (show) {
@ -96,6 +102,25 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
}
}, [isSuccess, onClose])
useEffect(() => {
setCurrentPage(1)
}, [selectedIds, searchTerm])
const filteredUsers = allUsers.filter(user =>
user.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
const sortedUsers = [...filteredUsers].sort((a, b) => {
const aSelected = selectedIds.includes(a.steamId) ? -1 : 0
const bSelected = selectedIds.includes(b.steamId) ? -1 : 0
return aSelected - bSelected
})
const unselectedUsers = filteredUsers.filter((user) => !selectedIds.includes(user.steamId))
const totalPages = Math.ceil(unselectedUsers.length / usersPerPage)
const startIdx = (currentPage - 1) * usersPerPage
const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + usersPerPage)
return (
<Modal
id="invite-members-modal"
@ -106,9 +131,57 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
closeButtonColor={isSuccess ? "teal" : "blue"}
closeButtonTitle={isSuccess ? "Einladungen versendet" : "Einladungen senden"}
>
<p className="text-sm text-gray-700 dark:text-neutral-300">
<p className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
Wähle Benutzer aus, die du in dein Team einladen möchtest:
</p>
{/* Ausgewählte Benutzer anzeigen */}
{selectedIds.length > 0 && (
<>
<div className="col-span-full">
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">
Ausgewählte Mitglieder:
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
<AnimatePresence initial={false}>
{selectedIds.map((id) => {
const user = allUsers.find((u) => u.steamId === id)
if (!user) return null
return (
<motion.div
key={user.steamId}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<MiniCard
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected={true}
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
hideActions={true}
/>
</motion.div>
)
})}
</AnimatePresence>
</div>
</div>
</>
)}
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mt-2 w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring focus:ring-blue-400 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
/>
{isSuccess && (
<div className="mt-2 px-4 py-2 text-sm text-green-700 bg-green-100 border border-green-200 rounded-lg">
{sentCount} Einladung{sentCount !== 1 ? 'en' : ''} erfolgreich versendet!
@ -117,22 +190,50 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team }: P
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4">
{isLoading ? (
<LoadingSpinner />
) : filteredUsers.length === 0 ? (
<div className="col-span-full text-center text-gray-500 dark:text-neutral-400">
{allUsers.length === 0
? 'Niemand zum Einladen verfügbar :('
: 'Keine Benutzer gefunden.'}
</div>
) : (
allUsers.map((user) => (
<MiniCard
key={user.steamId}
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected={selectedIds.includes(user.steamId)}
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
hideActions={true}
/>
))
<>
<AnimatePresence mode="popLayout" initial={false}>
{paginatedUsers
.filter((user) => !selectedIds.includes(user.steamId))
.map((user) => (
<motion.div
key={user.steamId}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<MiniCard
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected={false}
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={team.leader}
hideActions={true}
rank={user.premierRank}
/>
</motion.div>
))}
</AnimatePresence>
<div className="col-span-full flex justify-center mt-2">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={(page) => setCurrentPage(page)}
/>
</div>
</>
)}
</div>
</Modal>

View File

@ -8,11 +8,26 @@ import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Table from './Table'
import { useRouter } from 'next/navigation';
import PremierRankBadge from './PremierRankBadge'
import CompRankBadge from './CompRankBadge'
interface MatchDetailsProps {
match: Match
}
function calcKDR(kills?: number, deaths?: number): string {
if (typeof kills !== 'number' || typeof deaths !== 'number') return '-';
if (deaths === 0) return '∞';
return (kills / deaths).toFixed(2);
}
function calcADR(totalDamage?: number, roundCount?: number): string {
if (typeof totalDamage !== 'number' || typeof roundCount !== 'number' || roundCount === 0) {
return '-';
}
return (totalDamage / roundCount).toFixed(1);
}
export function MatchDetails({ match }: MatchDetailsProps) {
const router = useRouter();
@ -20,45 +35,94 @@ export function MatchDetails({ match }: MatchDetailsProps) {
? format(new Date(match.demoDate), 'PPpp', { locale: de })
: 'Unbekannt'
const renderPlayerTable = (players: MatchPlayer[]) => (
<Table>
<Table.Head>
<Table.Row>
<Table.Cell as='th'>Spieler</Table.Cell>
<Table.Cell as='th'>K</Table.Cell>
<Table.Cell as='th'>A</Table.Cell>
<Table.Cell as='th'>D</Table.Cell>
<Table.Cell as='th'>ADR</Table.Cell>
<Table.Cell as='th'>HS%</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{players.map((p, i) => (
<Table.Row
key={i}
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
<Table.Cell className="py-1 flex items-center gap-2">
{p.user.avatar && (
<img
src={p.user.avatar}
alt=""
className="w-6 h-6 rounded-full"
/>
)}
{p.user.name ?? 'Unbekannt'}
</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.adr?.toFixed(1) ?? '-'}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(1)}%</Table.Cell>
const renderPlayerTable = (players: MatchPlayer[]) => {
const sortedPlayers = [...players].sort((a, b) => {
const dmgA = a.stats?.totalDamage ?? 0;
const dmgB = b.stats?.totalDamage ?? 0;
return dmgB - dmgA;
});
console.log(match);
return (
<Table>
<Table.Head>
<Table.Row>
<Table.Cell as='th'>Spieler</Table.Cell>
<Table.Cell as='th'>Rank</Table.Cell>
<Table.Cell as='th'>K</Table.Cell>
<Table.Cell as='th'>A</Table.Cell>
<Table.Cell as='th'>D</Table.Cell>
<Table.Cell as='th'>1K</Table.Cell>
<Table.Cell as='th'>2K</Table.Cell>
<Table.Cell as='th'>3K</Table.Cell>
<Table.Cell as='th'>4K</Table.Cell>
<Table.Cell as='th'>5K</Table.Cell>
<Table.Cell as='th'>K/D</Table.Cell>
<Table.Cell as='th'>ADR</Table.Cell>
<Table.Cell as='th'>HS%</Table.Cell>
<Table.Cell as='th'>Damage</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)
</Table.Head>
<Table.Body>
{sortedPlayers.map((p: MatchPlayer, i) => (
<Table.Row
key={i}
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
<Table.Cell className="py-1 flex items-center gap-2">
{(
<img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name}
className="w-8 h-8 rounded-full"
/>
)}
{p.user.name ?? 'Unbekannt'}
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-[6px]">
{match.matchType === 'premier' ? (
<PremierRankBadge rank={p.stats?.rankNew ?? 0} />
) : (
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
)}
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span
className={`text-sm ${
p.stats?.rankChange > 0
? 'text-green-500'
: p.stats?.rankChange < 0
? 'text-red-500'
: ''
}`}
>
{p.stats?.rankChange > 0 ? '+' : ''}
{p.stats?.rankChange}
</span>
)}
</div>
</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k5 ?? '-'}</Table.Cell>
<Table.Cell>{calcKDR(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{calcADR(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)
}
return (
<div className="space-y-6">

View File

@ -100,6 +100,12 @@ export default function MatchesAdminManager() {
return (
<>
<div className="max-w-4xl mx-auto py-8 px-4 space-y-4">
<div className="max-w-4xl mx-auto px-4 flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">Geplante Matches</h1>
<Button onClick={() => setShowModal(true)} color="blue">
Neues Match erstellen
</Button>
</div>
{filteredMatches.length === 0 ? (
<p className="text-gray-500">Keine Matches geplant.</p>
) : (

View File

@ -95,7 +95,7 @@ export default function MiniCard({
<div className={avatarWrapper}>
<div className="relative w-16 h-16 mb-2">
<Image
src={avatar}
src={avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={title}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
@ -113,7 +113,7 @@ export default function MiniCard({
</div>
</div>
<span className="text-sm text-gray-800 dark:text-neutral-200 text-center mt-1 truncate max-w-[100px] w-full block">
<span className="text-sm text-gray-800 dark:text-neutral-200 text-center mt-1 truncate max-w-[100px] w-full block mb-1">
{title}
</span>

View File

@ -0,0 +1,110 @@
'use client'
type PaginationProps = {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
function getDisplayedPages(currentPage: number, totalPages: number): (number | '...')[] {
const delta = 1
const range: (number | '...')[] = []
const left = Math.max(2, currentPage - delta)
const right = Math.min(totalPages - 1, currentPage + delta)
range.push(1)
if (left > 2) range.push('...')
for (let i = left; i <= right; i++) range.push(i)
if (right < totalPages - 1) range.push('...')
if (totalPages > 1) range.push(totalPages)
return range
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
if (totalPages <= 1) return null
const pages = getDisplayedPages(currentPage, totalPages)
return (
<nav className="flex items-center gap-x-1 mt-2" aria-label="Pagination">
{/* Prev Button */}
<button
type="button"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="min-h-9.5 min-w-9.5 py-2 px-2.5 inline-flex justify-center items-center gap-x-2 text-sm rounded-lg border border-transparent text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
aria-label="Previous"
>
<svg
className="shrink-0 size-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
<span className="sr-only">Previous</span>
</button>
{/* Page Numbers */}
<div className="flex items-center gap-x-1">
{pages.map((page, index) =>
page === '...' ? (
<span
key={`ellipsis-${index}`}
className="min-h-9.5 min-w-9.5 flex justify-center items-center text-gray-500 dark:text-neutral-500 text-sm px-2"
>
...
</span>
) : (
<button
key={page}
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? 'page' : undefined}
className={`min-h-9.5 min-w-9.5 flex justify-center items-center py-2 px-3 text-sm rounded-lg border
${
page === currentPage
? 'border-gray-200 text-gray-800 dark:border-neutral-700 dark:text-white bg-gray-100 dark:bg-white/10'
: 'border-transparent text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-white/10'
}
focus:outline-hidden focus:bg-gray-100 dark:focus:bg-white/10`}
>
{page}
</button>
)
)}
</div>
{/* Next Button */}
<button
type="button"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="min-h-9.5 min-w-9.5 py-2 px-2.5 inline-flex justify-center items-center gap-x-2 text-sm rounded-lg border border-transparent text-gray-800 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 disabled:opacity-50 disabled:pointer-events-none dark:border-transparent dark:text-white dark:hover:bg-white/10 dark:focus:bg-white/10"
aria-label="Next"
>
<span className="sr-only">Next</span>
<svg
className="shrink-0 size-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
)
}

View File

@ -6,12 +6,12 @@ type Props = {
function getPremierRankColors(rank: number ): [string, string] {
const levels = [
{ min: 30000, stripe: '#EDC903', box: '#645104' },
{ min: 25000, stripe: '#DF1D1B', box: '#650A0E' },
{ min: 20000, stripe: '#F9243A', box: '#5C0361' },
{ min: 15000, stripe: '#DF1D1B', box: '#4D1D65' },
{ min: 30000, stripe: '#F1C406', box: '#645103' },
{ min: 25000, stripe: '#F62434', box: '#650A0E' },
{ min: 20000, stripe: '#D009DE', box: '#5A0360' },
{ min: 15000, stripe: '#AE56F0', box: '#491D64' },
{ min: 10000, stripe: '#3D5EDD', box: '#152062' },
{ min: 5000, stripe: '#74A9D5', box: '#74A9D5' },
{ min: 5000, stripe: '#72A9D8', box: '#2E465B' },
{ min: 0, stripe: '#A7B8CC', box: '#464952' },
];
@ -26,7 +26,7 @@ export default function PremierRankBadge({ rank }: Props) {
const [stripeColor, backgroundColor] = getPremierRankColors(rank);
if (typeof rank !== 'number' || isNaN(rank) || rank == 0) {
if (!rank || typeof rank !== 'number' || isNaN(rank)) {
return (
<div className="premier-rank-wrapper">
<svg className="rank-stripes" viewBox="0 0 12 28" xmlns="http://www.w3.org/2000/svg">

View File

@ -64,7 +64,7 @@ export default function SidebarFooter() {
{/* Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-full inline-flex items-center gap-x-2 px-4 py-3 text-sm text-left text-gray-800 transition-all duration-300
className={`w-full inline-flex items-center gap-x-2 px-4 py-3 text-sm text-left text-gray-800 transition-all duration-100
${isOpen ? 'bg-gray-100 dark:bg-neutral-700' : 'hover:bg-gray-100 dark:hover:bg-neutral-700'}
`}
>
@ -105,7 +105,7 @@ export default function SidebarFooter() {
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
transition={{ duration: 0.2 }}
className="overflow-hidden w-full bg-white shadow-lg dark:bg-neutral-800 dark:border-neutral-600 z-20"
>
<div className="p-2 flex flex-col gap-1">
@ -123,9 +123,9 @@ export default function SidebarFooter() {
Matches
</Link>
<Link
href="/profile"
href={`/profile/${user.steamId}`}
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${pathname === '/profile'
${pathname === `/profile/${user.steamId}`
? 'bg-gray-100 dark:bg-neutral-700 text-gray-900 dark:text-white'
: 'text-gray-800 hover:bg-gray-100 dark:text-neutral-300 dark:hover:bg-neutral-700'
}`}

View File

@ -54,7 +54,7 @@ export default function SortableMiniCard({
steamId={player.steamId}
title={player.name}
avatar={player.avatar}
rank={player.rank}
rank={player.premierRank}
location={player.location}
isLeader={player.steamId === teamLeaderSteamId}
draggable={isDraggable}

View File

@ -4,7 +4,7 @@ import { usePathname } from 'next/navigation'
import Link from 'next/link'
import type { ReactNode, ReactElement } from 'react'
type TabProps = {
export type TabProps = {
name: string
href: string
}
@ -15,32 +15,37 @@ export function Tabs({ children }: { children: ReactNode }) {
return (
<nav className="flex gap-x-1" aria-label="Tabs" role="tablist" aria-orientation="horizontal">
{tabs
{tabs
.filter(
(tab): tab is ReactElement<TabProps> =>
(tab): tab is ReactElement<TabProps> =>
tab !== null &&
typeof tab === 'object' &&
'props' in tab &&
typeof tab.props.href === 'string'
)
.map((tab, index) => {
const slug = tab.props.href.split('/').pop()
const isActive = pathname.endsWith(slug ?? '')
const slug = tab.props.href.split('/').pop()
const isActive = pathname.endsWith(slug ?? '')
return (
return (
<Link
key={index}
href={tab.props.href}
className={`py-2 px-4 text-sm rounded-lg transition-colors ${
key={index}
href={tab.props.href}
className={`py-2 px-4 text-sm rounded-lg transition-colors ${
isActive
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700'
}`}
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700'
}`}
>
{tab.props.name}
{tab.props.name}
</Link>
)
)
})}
</nav>
)
}
// Dummy-Komponente nur zur statischen Verwendung
Tabs.Tab = function Tab(_props: TabProps) {
return null
}

View File

@ -65,6 +65,7 @@ export default function TeamMemberView({
const [isEditingLogo, setIsEditingLogo] = useState(false)
const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [logoFile, setLogoFile] = useState<File | null>(null)
const [teamState, setTeamState] = useState<Team | null>(team)
useEffect(() => {
if (session?.user?.steamId) {
@ -72,6 +73,10 @@ export default function TeamMemberView({
}
}, [session?.user?.steamId])
useEffect(() => {
setTeamState(team)
}, [team])
useEffect(() => {
if (!source || !team?.id) return
@ -220,7 +225,7 @@ export default function TeamMemberView({
}
}
if (!team || !currentUserSteamId) return null
if (!teamState || !currentUserSteamId) return null
const renderMemberList = (players: Player[]) => (
<AnimatePresence>
@ -231,7 +236,7 @@ export default function TeamMemberView({
onKick={setKickCandidate}
onPromote={() => setPromoteCandidate(player)}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader}
teamLeaderSteamId={teamState.leader}
isDraggingGlobal={isDragging}
hideOverlay={isDragging}
matchParentBg={true}
@ -253,7 +258,7 @@ export default function TeamMemberView({
onClick={() => isLeader && document.getElementById('logoUpload')?.click()}
>
<Image
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/placeholder.png`}
src={teamState.logo ? `/assets/img/logos/${teamState.logo}` : `/assets/img/logos/placeholder.png`}
alt="Teamlogo"
fill
sizes="64px"
@ -290,7 +295,7 @@ export default function TeamMemberView({
const formData = new FormData()
formData.append('logo', file)
formData.append('teamId', team.id)
formData.append('teamId', teamState.id)
const res = await fetch('/api/team/upload-logo', {
method: 'POST',
@ -325,7 +330,8 @@ export default function TeamMemberView({
size="sm"
variant="soft"
onClick={async () => {
await renameTeam(team.id, editedName)
await renameTeam(teamState.id, editedName)
setTeamState((prev) => prev ? { ...prev, teamname: editedName } : prev)
setIsEditingName(false)
await reloadTeam()
}}
@ -353,7 +359,7 @@ export default function TeamMemberView({
variant="ghost"
onClick={() => {
setIsEditingName(false)
setEditedName(team.teamname ?? '')
setEditedName(teamState.teamname ?? '')
}}
className="h-[34px] px-3 flex items-center justify-center"
>
@ -375,7 +381,7 @@ export default function TeamMemberView({
<>
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
{team.teamname ?? 'Team'}
{teamState.teamname ?? 'Team'}
</h2>
<TeamPremierRankBadge players={activePlayers} />
</div>
@ -387,7 +393,7 @@ export default function TeamMemberView({
variant="soft"
onClick={() => {
setIsEditingName(true)
setEditedName(team.teamname || '')
setEditedName(teamState.teamname || '')
}}
className="h-[34px] px-3 flex items-center justify-center"
>
@ -472,7 +478,7 @@ export default function TeamMemberView({
<SortableMiniCard
player={activeDragItem}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader}
teamLeaderSteamId={teamState.leader}
hideOverlay
matchParentBg
/>
@ -482,8 +488,8 @@ export default function TeamMemberView({
{isLeader && (
<>
<LeaveTeamModal show={showLeaveModal} onClose={() => setShowLeaveModal(false)} onSuccess={() => setShowLeaveModal(false)} team={team} />
<InvitePlayersModal show={showInviteModal} onClose={() => setShowInviteModal(false)} onSuccess={() => {}} team={team} />
<LeaveTeamModal show={showLeaveModal} onClose={() => setShowLeaveModal(false)} onSuccess={() => setShowLeaveModal(false)} team={teamState} />
<InvitePlayersModal show={showInviteModal} onClose={() => setShowInviteModal(false)} onSuccess={() => {}} team={teamState} />
</>
)}
@ -532,7 +538,7 @@ export default function TeamMemberView({
await fetch('/api/team/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ teamId: team.id }),
body: JSON.stringify({ teamId: teamState.id }),
})
setShowDeleteModal(false)
window.location.href = '/'

View File

@ -9,7 +9,7 @@ type Props = {
export default function TeamPremierRankBadge({ players }: Props) {
const totalRank = players.reduce((sum, p) => {
return typeof p.rank === 'number' ? sum + p.rank : sum
return typeof p.premierRank === 'number' ? sum + p.premierRank : sum
}, 0)
return (

View File

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

View File

@ -19,22 +19,23 @@ interface Match {
rating: string;
kills: number;
deaths: number;
kd: string;
kdr: string;
rankNew: number;
rankOld: number;
rankChange: number;
}
export default function UserMatchesTable() {
export default function UserMatchesTable({ steamId }: { steamId: string }) {
const [matches, setMatches] = useState<Match[]>([]);
const router = useRouter();
useEffect(() => {
fetch('/api/user/matches')
if (!steamId) return
fetch(`/api/user/${steamId}/matches`)
.then((res) => res.json())
.then(setMatches)
.catch(console.error);
}, []);
}, [steamId])
return (
<Table>
@ -50,7 +51,7 @@ export default function UserMatchesTable() {
</Table.Row>
</Table.Head>
<Table.Body>
{matches.map((m) => {
{matches.map((m: Match) => {
const mapInfo = mapNameMap[m.map] ?? mapNameMap['lobby_mapveto'];
const [scoreCT, scoreT] = m.score.split(':').map(s => parseInt(s.trim(), 10));
@ -88,24 +89,26 @@ export default function UserMatchesTable() {
<img
src={`/assets/img/mapicons/${m.map}.webp`}
alt={mapInfo.name}
className="w-5 h-5 rounded"
height={32}
width={32}
/>
{mapInfo.name}
</div>
</Table.Cell>
<Table.Cell>{new Date(m.date).toLocaleString()}</Table.Cell>
<Table.Cell>
<span className={
left > right
? 'text-green-600 dark:text-green-400'
: left < right
? 'text-red-600 dark:text-red-400'
: 'text-yellow-600 dark:text-yellow-400'
}>
{m.score}
<span
className={
m.winnerTeam === m.team
? 'text-green-600 dark:text-green-400'
: m.winnerTeam && m.winnerTeam !== 'Draw'
? 'text-red-600 dark:text-red-400'
: 'text-yellow-600 dark:text-yellow-400'
}
>
{`${left} : ${right}`}
</span>
</Table.Cell>
<Table.Cell className="whitespace-nowrap">
<div className="flex items-center gap-[6px]">
{m.matchType === 'premier' ? (
@ -131,7 +134,7 @@ export default function UserMatchesTable() {
</Table.Cell>
<Table.Cell>{m.kills}</Table.Cell>
<Table.Cell>{m.deaths}</Table.Cell>
<Table.Cell>{m.kd}</Table.Cell>
<Table.Cell>{m.kdr}</Table.Cell>
</Table.Row>
);
})}

View File

@ -0,0 +1,216 @@
'use client'
import Chart from '@/app/components/Chart'
import { MatchStats } from '@/app/types/match'
import Card from './Card'
type MatchStatsProps = {
stats: { matches: MatchStats[] }
}
export default function UserProfile({ stats }: MatchStatsProps) {
const { matches } = stats
const totalKills = matches.reduce((sum, m) => sum + m.kills, 0)
const totalDeaths = matches.reduce((sum, m) => sum + m.deaths, 0)
const totalAssists = matches.reduce((sum, m) => sum + m.assists, 0)
const avgKDR = totalDeaths > 0 ? (totalKills / totalDeaths).toFixed(2) : '∞'
const premierMatches = matches.filter((m) => m.rankNew !== null && m.matchType === 'premier')
const compMatches = matches.filter((m) => m.rankNew !== null && m.matchType !== 'premier')
const killsPerMap = matches.reduce((acc, match) => {
acc[match.map] = (acc[match.map] || 0) + match.kills
return acc
}, {} as Record<string, number>)
const matchesPerMap = matches.reduce((acc, match) => {
acc[match.map] = (acc[match.map] || 0) + 1
return acc
}, {} as Record<string, number>)
return (
<div className="grid grid-cols-4 gap-4">
{/* K/D-Anteil + Zahl */}
<div className="space-y-4">
<Card>
<div className="relative mx-auto">
<Chart
type="doughnut"
title="Ø Gesamt-K/D"
labels={['Kills', 'Deaths']}
datasets={[
{
label: 'Anzahl',
data: [totalKills, totalDeaths],
backgroundColor: [
'rgba(54, 162, 235, 0.6)',
'rgba(255, 99, 132, 0.6)',
],
},
]}
hideLabels
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold text-gray-800 dark:text-white">{avgKDR}</p>
</div>
</div>
</div>
</Card>
{/* Kills vs Assists vs Deaths */}
<Card>
<Chart
type="doughnut"
title="Kills vs Assists vs Deaths"
labels={['Kills', 'Assists', 'Deaths']}
datasets={[
{
label: 'Anteile',
data: [totalKills, totalAssists, totalDeaths],
backgroundColor: [
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(255, 99, 132, 0.6)',
],
},
]}
/>
</Card>
</div>
{/* Breite Diagramme */}
<div className="col-span-3 space-y-6">
{/* Kills pro Match */}
<Chart
type="bar"
title="Kills pro Match"
labels={matches.map((m) => m.date)}
datasets={[
{
label: 'Kills',
data: matches.map((m) => m.kills),
backgroundColor: 'rgba(54, 162, 235, 0.6)',
},
]}
/>
{/* K/D pro Match */}
<Chart
type="line"
title="K/D Ratio pro Match"
labels={matches.map((m) => m.date)}
datasets={[
{
label: 'K/D',
data: matches.map((m) =>
m.deaths === 0 ? m.kills : m.kills / m.deaths
),
borderColor: 'rgba(255, 99, 132, 0.6)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderWidth: 2,
},
]}
/>
{/* Headshot % */}
<Chart
type="line"
title="Headshot % pro Match"
labels={matches.map((m) => m.date)}
datasets={[
{
label: 'HS%',
data: matches.map((m) => m.headshotPct),
borderColor: 'rgba(153, 102, 255, 0.6)',
backgroundColor: 'rgba(153, 102, 255, 0.2)',
borderWidth: 2,
},
]}
/>
{/* Damage */}
<Chart
type="bar"
title="Gesamtdamage pro Match"
labels={matches.map((m) => m.date)}
datasets={[
{
label: 'Damage',
data: matches.map((m) => m.totalDamage),
backgroundColor: 'rgba(255, 206, 86, 0.6)',
},
]}
/>
{/* Premier Rank-Verlauf */}
{premierMatches.length > 0 && (
<Chart
type="line"
title="Premier Rank-Verlauf"
labels={premierMatches.map((m) => m.date)}
datasets={[
{
label: 'Premier Rank',
data: premierMatches.map((m) => m.rankNew ?? 0),
borderColor: 'rgba(75, 192, 192, 0.6)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderWidth: 2,
},
]}
/>
)}
{/* Competitive Rank-Verlauf */}
{compMatches.length > 0 && (
<Chart
type="line"
title="Competitive Rank-Verlauf"
labels={compMatches.map((m) => m.date)}
datasets={[
{
label: 'Comp Rank',
data: compMatches.map((m) => m.rankNew ?? 0),
borderColor: 'rgba(255, 159, 64, 0.6)',
backgroundColor: 'rgba(255, 159, 64, 0.2)',
borderWidth: 2,
},
]}
/>
)}
{/* Kills pro Map */}
<Chart
type="bar"
title="Kills pro Map"
labels={Object.keys(killsPerMap)}
datasets={[
{
label: 'Kills',
data: Object.values(killsPerMap),
backgroundColor: 'rgba(255, 159, 64, 0.6)',
},
]}
/>
{/* Matches pro Map (Radar) */}
<Chart
type="radar"
title="Matches pro Map"
labels={Object.keys(matchesPerMap)}
datasets={[
{
label: 'Matches',
data: Object.values(matchesPerMap),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
},
]}
/>
</div>
</div>
)
}

View File

@ -20,8 +20,6 @@ export default async function MatchDetailsPage({ params }: PageProps) {
const match: Match = await res.json()
console.log(match)
return (
<Card maxWidth="auto">
<MatchDetails match={match} />

View File

@ -1,17 +1,16 @@
'use client'
// /app/profile/[steamId]/matches/page.tsx
import UserMatchesTable from '@/app/components/UserMatchesTable'
import Card from '../components/Card'
import Card from '@/app/components/Card'
export default function MatchesPage() {
export default function MatchesPage({ params }: { params: { steamId: string } }) {
return (
<div className="max-w-6xl mx-auto py-8 px-4">
<h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300 mb-6">
Deine Matches
</h1>
<Card maxWidth='auto'>
<UserMatchesTable />
<Card maxWidth="auto">
<UserMatchesTable steamId={params.steamId} />
</Card>
</div>
)

View File

@ -0,0 +1,35 @@
// /app/profile/[steamId]/layout.tsx
import type { ReactNode } from 'react'
import { notFound } from 'next/navigation'
import { prisma } from '@/app/lib/prisma'
import Card from '@/app/components/Card'
import UserHeader from '@/app/components/UserHeader'
export default async function ProfileLayout({
children,
params,
}: {
children: ReactNode
params: { steamId: string }
}) {
const user = await prisma.user.findUnique({
where: { steamId: params.steamId },
select: {
steamId: true,
name: true,
avatar: true,
premierRank: true,
},
})
if (!user) return notFound()
return (
<Card maxWidth="auto">
<div className="max-w-4xl mx-auto py-8 px-4 space-y-6">
<UserHeader steamId={user.steamId} name={user.name ?? ''} avatar={user.avatar} premierRank={user.premierRank} />
<div className="pt-6">{children}</div>
</div>
</Card>
)
}

View File

@ -0,0 +1,11 @@
// /app/profile/[steamId]/matches/page.tsx
import Card from '@/app/components/Card'
import UserMatchesTable from '@/app/components/UserMatchesTable'
export default function MatchesPage({ params }: { params: { steamId: string } }) {
return (
<Card maxWidth="auto">
<UserMatchesTable steamId={params.steamId} />
</Card>
)
}

View File

@ -0,0 +1,6 @@
// /app/profile/[steamId]/page.tsx
import { redirect } from 'next/navigation'
export default function ProfileRedirect({ params }: { params: { steamId: string } }) {
redirect(`/profile/${params.steamId}/stats`)
}

View File

@ -0,0 +1,18 @@
// /app/profile/[steamId]/stats/page.tsx
import UserProfile from '@/app/components/UserProfile'
import { MatchStats } from '@/app/types/match'
async function getStats(steamId: string) {
const res = await fetch(`http://localhost:3000/api/stats/${steamId}`, {
cache: 'no-store',
})
if (!res.ok) return null
return res.json()
}
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 <UserProfile stats={{ matches: data.stats as MatchStats[] }} />
}

View File

@ -1,8 +1,14 @@
export default function Profile() {
return (
<>
<h1>Profil</h1>
</>
)
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
export default async function ProfileRedirectPage(req: NextRequest) {
const session = await getServerSession(authOptions(req))
if (!session?.user?.steamId) {
redirect('/login') // Wenn nicht eingeloggt, zur Loginseite
}
redirect(`/profile/${session.user.steamId}`)
}

View File

@ -4,6 +4,7 @@ import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Switch from '@/app/components/Switch'
import Button from '../components/Button'
type Match = {
id: string
@ -19,9 +20,18 @@ export default function MatchesPage() {
const [onlyOwnTeam, setOnlyOwnTeam] = useState(false)
useEffect(() => {
fetch('/api/matches')
fetch('/api/schedule')
.then(res => res.json())
.then(setMatches)
.then(data => {
if (Array.isArray(data)) {
setMatches(data)
} else if (Array.isArray(data.schedules)) {
setMatches(data.schedules)
} else {
console.error("❌ Unerwartetes API-Format", data)
setMatches([])
}
})
}, [])
const filteredMatches = onlyOwnTeam && session?.user?.team
@ -43,6 +53,13 @@ export default function MatchesPage() {
labelRight="Nur mein Team"
/>
)}
{session?.user?.isAdmin && (
<Link href="/admin/matches">
<Button color="blue">Match erstellen</Button>
</Link>
)}
</div>
{filteredMatches.length === 0 ? (

View File

@ -7,6 +7,7 @@ export type Match = {
description?: string
map: string
matchType: string
roundCount: number
scoreA?: number | null
scoreB?: number | null
teamA: {
@ -31,7 +32,39 @@ export type MatchPlayer = {
kills: number
deaths: number
assists: number
adr: number
totalDamage: number
utilityDamage: number
headshotPct: number
flashAssists: number
mvps: number
knifeKills: number
zeusKills: number
wallbangKills: number
smokeKills: number
headshots: number
noScopes: number
blindKills: Number
rankOld: number
rankNew: number
rankChange: number
k1: number
k2: number
k3: number
k4: number
k5: number
}
}
export type MatchStats = {
date: string
kills: number
deaths: number
assists: number
headshotPct: number,
totalDamage: number,
map: string,
matchType: 'premier' | 'competitive' | 'community' // falls du auch andere hast
rankNew: number | null
rankOld?: number | null
}

View File

@ -4,7 +4,7 @@ export type Player = {
name: string
avatar: string
location?: string
rank?: number
premierRank?: number
isAdmin?: boolean
}

File diff suppressed because one or more lines are too long

View File

@ -214,8 +214,14 @@ exports.Prisma.PlayerStatsScalarFieldEnum = {
headshots: 'headshots',
noScopes: 'noScopes',
blindKills: 'blindKills',
k1: 'k1',
k2: 'k2',
k3: 'k3',
k4: 'k4',
k5: 'k5',
rankOld: 'rankOld',
rankNew: 'rankNew',
rankChange: 'rankChange',
winCount: 'winCount'
};
@ -230,6 +236,22 @@ exports.Prisma.RankHistoryScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.ScheduleScalarFieldEnum = {
id: 'id',
title: 'title',
description: 'description',
map: 'map',
date: 'date',
status: 'status',
teamAId: 'teamAId',
teamBId: 'teamBId',
createdById: 'createdById',
confirmedById: 'confirmedById',
linkedMatchId: 'linkedMatchId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.DemoFileScalarFieldEnum = {
id: 'id',
matchId: 'matchId',
@ -276,7 +298,13 @@ exports.Prisma.JsonNullValueFilter = {
JsonNull: Prisma.JsonNull,
AnyNull: Prisma.AnyNull
};
exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
DECLINED: 'DECLINED',
CANCELLED: 'CANCELLED',
COMPLETED: 'COMPLETED'
};
exports.Prisma.ModelName = {
User: 'User',
@ -287,6 +315,7 @@ exports.Prisma.ModelName = {
MatchPlayer: 'MatchPlayer',
PlayerStats: 'PlayerStats',
RankHistory: 'RankHistory',
Schedule: 'Schedule',
DemoFile: 'DemoFile',
ServerRequest: 'ServerRequest'
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"name": "prisma-client-81fe4a88a75a445ee239171472edc8b1edb558143434347db08d32212685268e",
"name": "prisma-client-79a53e7403334d4969ea3ed05480c7d287e1fc81fe58f157629c22acbdb9958c",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@ -40,6 +40,9 @@ model User {
serverRequests ServerRequest[] @relation("MatchRequests")
rankHistory RankHistory[] @relation("UserRankHistory")
demoFiles DemoFile[]
createdSchedules Schedule[] @relation("CreatedSchedules")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
}
model Team {
@ -59,6 +62,9 @@ model Team {
matchesAsTeamA Match[] @relation("MatchTeamA")
matchesAsTeamB Match[] @relation("MatchTeamB")
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
}
model TeamInvite {
@ -124,6 +130,8 @@ model Match {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
schedule Schedule?
}
model MatchPlayer {
@ -168,9 +176,16 @@ model PlayerStats {
noScopes Int @default(0)
blindKills Int @default(0)
rankOld Int?
rankNew Int?
winCount Int?
k1 Int @default(0)
k2 Int @default(0)
k3 Int @default(0)
k4 Int @default(0)
k5 Int @default(0)
rankOld Int?
rankNew Int?
rankChange Int?
winCount Int?
matchPlayer MatchPlayer @relation(fields: [matchId, steamId], references: [matchId, steamId])
@ -193,6 +208,41 @@ model RankHistory {
match Match? @relation("MatchRankHistory", fields: [matchId], references: [id])
}
model Schedule {
id String @id @default(uuid())
title String
description String?
map String?
date DateTime
status ScheduleStatus @default(PENDING)
teamAId String?
teamA Team? @relation("ScheduleTeamA", fields: [teamAId], references: [id])
teamBId String?
teamB Team? @relation("ScheduleTeamB", fields: [teamBId], references: [id])
createdById String
createdBy User @relation("CreatedSchedules", fields: [createdById], references: [steamId])
confirmedById String?
confirmedBy User? @relation("ConfirmedSchedules", fields: [confirmedById], references: [steamId])
linkedMatchId String? @unique
linkedMatch Match? @relation(fields: [linkedMatchId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum ScheduleStatus {
PENDING
CONFIRMED
DECLINED
CANCELLED
COMPLETED
}
//
// ──────────────────────────────────────────────
// 📦 Demo-Dateien & CS2 Requests

View File

@ -214,8 +214,14 @@ exports.Prisma.PlayerStatsScalarFieldEnum = {
headshots: 'headshots',
noScopes: 'noScopes',
blindKills: 'blindKills',
k1: 'k1',
k2: 'k2',
k3: 'k3',
k4: 'k4',
k5: 'k5',
rankOld: 'rankOld',
rankNew: 'rankNew',
rankChange: 'rankChange',
winCount: 'winCount'
};
@ -230,6 +236,22 @@ exports.Prisma.RankHistoryScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.ScheduleScalarFieldEnum = {
id: 'id',
title: 'title',
description: 'description',
map: 'map',
date: 'date',
status: 'status',
teamAId: 'teamAId',
teamBId: 'teamBId',
createdById: 'createdById',
confirmedById: 'confirmedById',
linkedMatchId: 'linkedMatchId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.DemoFileScalarFieldEnum = {
id: 'id',
matchId: 'matchId',
@ -276,7 +298,13 @@ exports.Prisma.JsonNullValueFilter = {
JsonNull: Prisma.JsonNull,
AnyNull: Prisma.AnyNull
};
exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
DECLINED: 'DECLINED',
CANCELLED: 'CANCELLED',
COMPLETED: 'COMPLETED'
};
exports.Prisma.ModelName = {
User: 'User',
@ -287,6 +315,7 @@ exports.Prisma.ModelName = {
MatchPlayer: 'MatchPlayer',
PlayerStats: 'PlayerStats',
RankHistory: 'RankHistory',
Schedule: 'Schedule',
DemoFile: 'DemoFile',
ServerRequest: 'ServerRequest'
};

View File

@ -11,6 +11,12 @@ export async function getNextShareCodeFromAPI(
},
body: JSON.stringify({ steamId, authCode, currentCode }),
});
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return null;
}
const data = await res.json();

View File

@ -30,7 +30,13 @@ interface PlayerStatsExtended {
utilityDamage: number;
rankOld?: number;
rankNew?: number;
rankChange?: number;
winCount?: number;
k1?: number,
k2?: number,
k3?: number,
k4?: number,
k5?: number,
}
interface DemoMatchData {
@ -147,17 +153,54 @@ export async function parseAndStoreDemo(
}
}
const allRanks = [
...(parsed.meta.teamA?.players || []),
...(parsed.meta.teamB?.players || []),
].map(p => p.rankNew).filter(r => typeof r === 'number');
const inferredMatchType =
allRanks.length > 0 && allRanks.every(r => r >= 1000)
? 'premier'
: 'competitive';
if (inferredMatchType === 'premier' && actualDemoPath.toLowerCase().endsWith('_competitive.dem')) {
const oldPath = actualDemoPath;
const newPath = actualDemoPath.replace(/_competitive\.dem$/i, '_premier.dem');
try {
await fs.rename(oldPath, newPath);
actualDemoPath = newPath;
const jsonOldPath = oldPath.replace(/\.dem$/i, '.json');
const jsonNewPath = newPath.replace(/\.dem$/i, '.json');
await fs.rename(jsonOldPath, jsonNewPath);
} catch (err) {
console.warn('⚠️ Fehler beim Umbenennen auf "_premier.dem":', err);
}
} else if (inferredMatchType === 'competitive' && actualDemoPath.toLowerCase().endsWith('_premier.dem')) {
const oldPath = actualDemoPath;
const newPath = actualDemoPath.replace(/_premier\.dem$/i, '_competitive.dem');
try {
await fs.rename(oldPath, newPath);
actualDemoPath = newPath;
const jsonOldPath = oldPath.replace(/\.dem$/i, '.json');
const jsonNewPath = newPath.replace(/\.dem$/i, '.json');
await fs.rename(jsonOldPath, jsonNewPath);
} catch (err) {
console.warn('⚠️ Fehler beim Umbenennen auf "_competitive.dem":', err);
}
}
const match = await prisma.match.create({
data: {
id: parsed.matchId,
title: `CS2 Match auf ${parsed.map} am ${parsed.meta.demoDate?.toLocaleDateString('de-DE') ?? 'unbekannt'}`,
map: parsed.map,
filePath: relativePath,
matchType: demoPath.endsWith('_premier.dem')
? 'premier'
: demoPath.endsWith('_competitive.dem')
? 'competitive'
: 'community',
matchType: inferredMatchType,
scoreA: parsed.meta.teamA?.score,
scoreB: parsed.meta.teamB?.score,
winnerTeam: parsed.meta.winnerTeam ?? null,
@ -227,7 +270,13 @@ export async function parseAndStoreDemo(
blindKills: player.blindKills,
rankOld: player.rankOld ?? null,
rankNew: player.rankNew ?? null,
rankChange: player.rankChange ?? null,
winCount: player.winCount ?? null,
k1: player.k1 ?? 0,
k2: player.k2 ?? 0,
k3: player.k3 ?? 0,
k4: player.k4 ?? 0,
k5: player.k5 ?? 0,
},
create: {
id: matchPlayer.id,
@ -253,7 +302,13 @@ export async function parseAndStoreDemo(
blindKills: player.blindKills,
rankOld: player.rankOld ?? null,
rankNew: player.rankNew ?? null,
rankChange: player.rankChange ?? null,
winCount: player.winCount ?? null,
k1: player.k1 ?? 0,
k2: player.k2 ?? 0,
k3: player.k3 ?? 0,
k4: player.k4 ?? 0,
k5: player.k5 ?? 0,
},
});
}
@ -280,8 +335,6 @@ async function parseDemoViaGo(filePath: string, shareCode: string): Promise<Demo
if (code === 0) {
try {
const parsed = JSON.parse(output);
console.log(parsed.teamA.players);
console.log(parsed.teamB.players);
resolve({
matchId,
map: parsed.map,

View File

@ -92,7 +92,7 @@ async function runMatchCheck() {
});
if (existingMatch) {
log(`[${user.steamId}] ↪️ Match ${matchInfo.matchId} existiert bereits übersprungen`);
// log(`[${user.steamId}] ↪️ Match ${matchInfo.matchId} existiert bereits übersprungen`);
await prisma.user.update({
where: { steamId: user.steamId },
data: { lastKnownShareCode: nextShareCode },

View File

@ -36,11 +36,11 @@ const server = http.createServer((req, res) => {
res.write('\n') // Verbindung offen halten
clients.set(steamId, res)
console.log(`[SSE] Verbunden: steamId=${steamId}`)
//console.log(`[SSE] Verbunden: steamId=${steamId}`)
req.on('close', () => {
clients.delete(steamId)
console.log(`[SSE] Verbindung geschlossen: steamId=${steamId}`)
//console.log(`[SSE] Verbindung geschlossen: steamId=${steamId}`)
})
return
}
@ -70,7 +70,7 @@ const server = http.createServer((req, res) => {
}
}
console.log(`[SSE] Nachricht vom Typ "${type}" an ${sentCount} Client(s) gesendet.`)
//console.log(`[SSE] Nachricht vom Typ "${type}" an ${sentCount} Client(s) gesendet.`)
res.writeHead(200)
res.end('Nachricht gesendet.')
})