update
This commit is contained in:
parent
2675c6363c
commit
c8945e55d8
56
package-lock.json
generated
56
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
public/assets/img/avatars/default_steam_avatar.jpg
Normal file
BIN
public/assets/img/avatars/default_steam_avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@ -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,
|
||||
|
||||
46
src/app/api/schedule/route.ts
Normal file
46
src/app/api/schedule/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
66
src/app/api/stats/[steamId]/route.ts
Normal file
66
src/app/api/stats/[steamId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ export async function GET() {
|
||||
name: true,
|
||||
avatar: true,
|
||||
location: true,
|
||||
premierRank: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
|
||||
@ -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.`,
|
||||
},
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
91
src/app/api/user/[steamId]/matches/route.ts
Normal file
91
src/app/api/user/[steamId]/matches/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
39
src/app/api/user/[steamId]/route.ts
Normal file
39
src/app/api/user/[steamId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
101
src/app/components/Chart.tsx
Normal file
101
src/app/components/Chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
110
src/app/components/Pagination.tsx
Normal file
110
src/app/components/Pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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'
|
||||
}`}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 = '/'
|
||||
|
||||
@ -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 (
|
||||
|
||||
40
src/app/components/UserHeader.tsx
Normal file
40
src/app/components/UserHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
216
src/app/components/UserProfile.tsx
Normal file
216
src/app/components/UserProfile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
35
src/app/profile/[steamId]/layout.tsx
Normal file
35
src/app/profile/[steamId]/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
src/app/profile/[steamId]/matches/page.tsx
Normal file
11
src/app/profile/[steamId]/matches/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
src/app/profile/[steamId]/page.tsx
Normal file
6
src/app/profile/[steamId]/page.tsx
Normal 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`)
|
||||
}
|
||||
18
src/app/profile/[steamId]/stats/page.tsx
Normal file
18
src/app/profile/[steamId]/stats/page.tsx
Normal 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[] }} />
|
||||
}
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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'
|
||||
};
|
||||
|
||||
3666
src/generated/prisma/index.d.ts
vendored
3666
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-81fe4a88a75a445ee239171472edc8b1edb558143434347db08d32212685268e",
|
||||
"name": "prisma-client-79a53e7403334d4969ea3ed05480c7d287e1fc81fe58f157629c22acbdb9958c",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp12192
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp12192
Normal file
Binary file not shown.
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp20052
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp20052
Normal file
Binary file not shown.
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp27320
Normal file
BIN
src/generated/prisma/query_engine-windows.dll.node.tmp27320
Normal file
Binary file not shown.
@ -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
|
||||
|
||||
@ -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'
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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.')
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user