This commit is contained in:
Linrador 2025-08-11 15:36:08 +02:00
parent 55a12c1f68
commit af9fe48584
36 changed files with 1681 additions and 957 deletions

View File

@ -176,11 +176,13 @@ model PlayerStats {
noScopes Int @default(0) noScopes Int @default(0)
blindKills Int @default(0) blindKills Int @default(0)
k1 Int @default(0) aim Int @default(0)
k2 Int @default(0)
k3 Int @default(0) oneK Int @default(0)
k4 Int @default(0) twoK Int @default(0)
k5 Int @default(0) threeK Int @default(0)
fourK Int @default(0)
fiveK Int @default(0)
rankOld Int? rankOld Int?
rankNew Int? rankNew Int?

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -3,57 +3,154 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST (req: NextRequest) { export async function POST (req: NextRequest) {
/* ── Auth ▸ nur Admins ───────────────────────────── */ // ── Auth: nur Admins
const session = await getServerSession(authOptions(req)) const session = await getServerSession(authOptions(req))
if (!session?.user?.isAdmin) if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
/* ── Body auslesen ──────────────────────────────── */ // ── Body
const { teamAId, teamBId, title, description, matchDate, map } = await req.json() const body = await req.json().catch(() => ({}))
if (!teamAId || !teamBId || !matchDate) const {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 }) teamAId,
teamBId,
title: rawTitle,
description,
matchDate,
map,
bestOf, // 3 | 5 (optional; default 3)
} = body as {
teamAId?: string
teamBId?: string
title?: string
description?: string
matchDate?: string | number | Date
map?: string
bestOf?: 3 | 5 | number
}
/* ── Teams inkl. aktiver Spieler laden ───────────── */ if (!teamAId || !teamBId) {
return NextResponse.json({ error: 'Missing team ids' }, { status: 400 })
}
if (teamAId === teamBId) {
return NextResponse.json({ error: 'Teams must be different' }, { status: 400 })
}
const bestOfInt: 3 | 5 = bestOf === 5 ? 5 : 3
const plannedAt = matchDate ? new Date(matchDate) : new Date()
if (Number.isNaN(plannedAt.getTime())) {
return NextResponse.json({ error: 'Invalid matchDate' }, { status: 400 })
}
// ── Teams laden
const [teamA, teamB] = await Promise.all([ const [teamA, teamB] = await Promise.all([
prisma.team.findUnique({ where: { id: teamAId }, select: { activePlayers: true } }), prisma.team.findUnique({
prisma.team.findUnique({ where: { id: teamBId }, select: { activePlayers: true } }), where: { id: teamAId },
select: { id: true, name: true, logo: true, leaderId: true, activePlayers: true, inactivePlayers: true },
}),
prisma.team.findUnique({
where: { id: teamBId },
select: { id: true, name: true, logo: true, leaderId: true, activePlayers: true, inactivePlayers: true },
}),
]) ])
if (!teamA || !teamB) if (!teamA || !teamB) {
return NextResponse.json({ error: 'Team not found' }, { status: 404 }) return NextResponse.json({ error: 'Team not found' }, { status: 404 })
}
const safeTitle = (rawTitle?.trim() || `${teamA.name ?? teamA.id} vs ${teamB.name ?? teamB.id}`)
const safeDesc = description?.trim() || null
const safeMap = map?.trim() || null
/* ── Match + Spieler in EINER Transaktion ────────── */
try { try {
const result = await prisma.$transaction(async (tx) => { // ── Anlegen in Transaktion
/* 1) Match mit verbundenen Team-User-Arrays anlegen */ const created = await prisma.$transaction(async (tx) => {
const newMatch = await tx.match.create({ const newMatch = await tx.match.create({
data: { data: {
teamAId, teamAId,
teamBId, teamBId,
title : title?.trim() || `${teamAId}-${teamBId}`, title : safeTitle,
description : description?.trim() || null, description : safeDesc,
map : map?.trim() || null, map : safeMap,
demoDate : new Date(matchDate), demoDate : plannedAt,
// ⚠ hier KEIN "type" setzen existiert nicht im Schema
/* aktive Spieler direkt verbinden */ teamAUsers : { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
teamAUsers: { connect: teamA.activePlayers.map(id => ({ steamId: id })) }, teamBUsers : { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) },
teamBUsers: { connect: teamB.activePlayers.map(id => ({ steamId: id })) },
}, },
}) })
/* 2) separate MatchPlayer-Zeilen */
const playersData = [ const playersData = [
...teamA.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamAId })), ...(teamA.activePlayers ?? []).map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamAId })),
...teamB.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamBId })), ...(teamB.activePlayers ?? []).map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamBId })),
] ]
if (playersData.length) if (playersData.length) {
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true }) await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
}
return newMatch return newMatch
}) })
return NextResponse.json(result, { status: 201 }) // ── Notifications + SSE
const targets = Array.from(new Set<string>([
teamA.leaderId,
...(teamA.activePlayers ?? []),
...(teamA.inactivePlayers ?? []),
teamB.leaderId,
...(teamB.activePlayers ?? []),
...(teamB.inactivePlayers ?? []),
].filter(Boolean) as string[]))
const admins = await prisma.user.findMany({ where: { isAdmin: true }, select: { steamId: true } })
const allTargets = Array.from(new Set([...targets, ...admins.map(a => a.steamId)]))
const boLabel = bestOfInt === 5 ? 'BO5' : 'BO3'
const when = plannedAt.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
const message = `Neues Match geplant: „${safeTitle}“ (${boLabel}) ${when}.`
if (allTargets.length) {
const notifs = await Promise.all(
allTargets.map(uid =>
prisma.notification.create({
data: {
steamId : uid,
title : 'Match erstellt',
message,
actionType: 'match-created',
actionData: created.id,
},
})
)
)
await Promise.all(
notifs.map(n =>
sendServerSSEMessage({
type : 'notification',
targetUserIds: [n.steamId],
message : n.message,
id : n.id,
actionType : n.actionType ?? undefined,
actionData : n.actionData ?? undefined,
createdAt : n.createdAt.toISOString(),
})
)
)
}
await sendServerSSEMessage({
type: 'matches-updated',
targetUserIds: allTargets,
message: 'Neue Matchplanung verfügbar.',
})
return NextResponse.json(
{ success: true, match: created },
{ status: 201, headers: { 'Cache-Control': 'no-store' } },
)
} catch (err) { } catch (err) {
console.error('POST /matches/create failed:', err) console.error('POST /matches/create failed:', err)
return NextResponse.json({ error: 'Failed to create match' }, { status: 500 }) return NextResponse.json({ error: 'Failed to create match' }, { status: 500 })

View File

@ -1,10 +1,15 @@
import { NextResponse } from 'next/server' // /src/app/api/team/available-users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
export async function GET() { export async function GET(req: NextRequest) {
try { try {
// 1. Alle offenen Einladungen (inkl. User-Infos) const { searchParams } = new URL(req.url)
const teamId = searchParams.get('teamId') ?? undefined
// 1) Nur Pending-Invites DIESES Teams laden (damit wir doppelte Einladungen dieses Teams vermeiden)
const pendingInvites = await prisma.teamInvite.findMany({ const pendingInvites = await prisma.teamInvite.findMany({
where: teamId ? { teamId } : undefined,
include: { include: {
user: { user: {
select: { select: {
@ -19,31 +24,23 @@ export async function GET() {
}, },
}) })
// 2. Steam-IDs aus Einladungen extrahieren // 2) Nur die von DIESEM Team bereits eingeladenen Steam-IDs
const invitedSteamIds = new Set( const invitedByThisTeam = new Set(
pendingInvites pendingInvites.map(inv => inv.user?.steamId).filter(Boolean) as string[]
.map(inv => inv.user?.steamId)
.filter(Boolean)
) )
// 3. Alle Teams laden (nur SteamIDs) // 3) (Optional/robust) Mitglieder aller Teams sammeln doppelt gemoppelt zu where: { team: null },
// aber falls du später das where lockerst, bleibt es sicher.
const teams = await prisma.team.findMany({ const teams = await prisma.team.findMany({
select: { select: { activePlayers: true, inactivePlayers: true },
activePlayers: true,
inactivePlayers: true,
},
}) })
// 4. Steam-IDs aller Teammitglieder sammeln
const teamMemberIds = new Set( const teamMemberIds = new Set(
teams.flatMap(team => [...team.activePlayers, ...team.inactivePlayers]) teams.flatMap(t => [...t.activePlayers, ...t.inactivePlayers])
) )
// 5. Alle Benutzer laden, die kein Team haben // 4) Nur Nutzer ohne Team laden
const allUsers = await prisma.user.findMany({ const allUsers = await prisma.user.findMany({
where: { where: { team: null }, // hat noch kein Team
team: null,
},
select: { select: {
steamId : true, steamId : true,
name : true, name : true,
@ -51,15 +48,13 @@ export async function GET() {
location : true, location : true,
premierRank: true, premierRank: true,
}, },
orderBy: { orderBy: { name: 'asc' },
name: 'asc',
},
}) })
// 6. Nur Benutzer behalten, die **nicht** eingeladen und **nicht** bereits im Team sind // 5) Verfügbar = kein Mitglied + NICHT bereits von DIESEM Team eingeladen
const availableUsers = allUsers.filter(user => const availableUsers = allUsers.filter(u =>
!invitedSteamIds.has(user.steamId) && !teamMemberIds.has(u.steamId) &&
!teamMemberIds.has(user.steamId) !invitedByThisTeam.has(u.steamId)
) )
return NextResponse.json({ users: availableUsers }) return NextResponse.json({ users: availableUsers })

View File

@ -3,17 +3,18 @@ import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
/* ───── Request-Body ───── */ /* ───── Request-Body ───── */
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json() const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
/* ► Teamname pflicht */
if (!teamname?.trim()) { if (!teamname?.trim()) {
return NextResponse.json({ message: 'Teamname fehlt.' }, { status: 400 }) return NextResponse.json({ message: 'Teamname fehlt.' }, { status: 400 })
} }
/* ► Name schon vergeben? */ // Optionaler Vorab-Check (Unique-Constraint sollte zusätzlich auf DB-Ebene existieren)
const dup = await prisma.team.findFirst({ where: { name: teamname } }) const dup = await prisma.team.findFirst({ where: { name: teamname } })
if (dup) { if (dup) {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 }) return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
@ -23,36 +24,51 @@ export async function POST(req: NextRequest) {
const newTeam = await prisma.team.create({ const newTeam = await prisma.team.create({
data: { data: {
name: teamname, name: teamname,
leaderId : leader ?? null, // ← nur setzen, wenn übergeben leaderId: leader ?? null,
activePlayers: leader ? [leader] : [], activePlayers: leader ? [leader] : [],
inactivePlayers: [], inactivePlayers: [],
}, },
}) })
/* ───── Optional: Leader verknüpfen ───── */ /* ───── Leader verknüpfen + Notification ───── */
if (leader) { if (leader) {
const user = await prisma.user.findUnique({ where: { steamId: leader } }) const user = await prisma.user.findUnique({ where: { steamId: leader } })
if (!user) { if (!user) {
// Rollback Team und Fehler ausgeben // Rollback Team und Fehler
await prisma.team.delete({ where: { id: newTeam.id } }) await prisma.team.delete({ where: { id: newTeam.id } })
return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 }) return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 })
} }
// User an Team hängen
await prisma.user.update({ await prisma.user.update({
where: { steamId: leader }, where: { steamId: leader },
data: { teamId: newTeam.id }, data: { teamId: newTeam.id },
}) })
await prisma.notification.create({ // Persistente Notification
const note = await prisma.notification.create({
data: { data: {
steamId: leader, steamId: leader,
title: 'Team erstellt', title: 'Team erstellt',
message: `Du hast erfolgreich das Team „${teamname}“ erstellt.`, message: `Du hast erfolgreich das Team „${teamname}“ erstellt.`,
actionType: 'team-created',
actionData: newTeam.id,
}, },
}) })
// ➜ Sofortige Live-Zustellung an den Leader
await sendServerSSEMessage({
type: 'notification', // wichtig fürs NotificationCenter
targetUserIds: [leader],
message: note.message,
id: note.id,
actionType: note.actionType ?? undefined,
actionData: note.actionData ?? undefined,
createdAt: note.createdAt.toISOString(),
})
} }
/* ───── SSE an alle raus ───── */ /* ───── Optionale Info: Broadcast (falls du den Eventtyp nutzt) ───── */
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-created', type: 'team-created',
title: 'Team erstellt', title: 'Team erstellt',
@ -60,8 +76,21 @@ export async function POST(req: NextRequest) {
teamId: newTeam.id, teamId: newTeam.id,
}) })
return NextResponse.json({ message: 'Team erstellt', team: newTeam }) /* ───── Failsafe/Listen-Refresh für alle Clients ───── */
} catch (error) { await sendServerSSEMessage({
type: 'team-updated', // von deinen Views bereits beobachtet
teamId: newTeam.id,
})
return NextResponse.json(
{ message: 'Team erstellt', team: newTeam },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (error: any) {
// Unique-Constraint sauber abfangen (falls zwei Requests gleichzeitig kommen)
if (error?.code === 'P2002') {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
}
console.error('❌ Fehler beim Team erstellen:', error) console.error('❌ Fehler beim Team erstellen:', error)
return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 }) return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 })
} }

View File

@ -1,17 +1,137 @@
// /app/api/team/delete/route.ts // /app/api/team/delete/route.ts
import { NextResponse } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export async function POST(req: Request) { export const dynamic = 'force-dynamic'
const body = await req.json()
const { teamId } = body
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions(req))
const steamId = session?.user?.steamId
const isAdmin = !!session?.user?.isAdmin
if (!steamId) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
const { teamId } = await req.json().catch(() => ({}))
if (!teamId) { if (!teamId) {
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 }) return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
} }
try { // Team laden (für Berechtigung + spätere Benachrichtigung)
await prisma.team.delete({ where: { id: teamId } }) const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
id: true,
name: true,
leaderId: true,
activePlayers: true,
inactivePlayers: true,
},
})
if (!team) {
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
}
// Nur Admin oder Team-Lead darf löschen
if (!isAdmin && steamId !== team.leaderId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// ---- Alles in einer Transaktion vorbereiten/aufräumen ----
const { inviteeIds, memberIds } = await prisma.$transaction(async (tx) => {
// 1) Offene Invites des Teams holen (egal ob team-invite oder join-request)
const invites = await tx.teamInvite.findMany({
where: { teamId: team.id },
select: { id: true, steamId: true },
})
const inviteIds = invites.map(i => i.id)
// 2) Zugehörige Notifications neutralisieren
if (inviteIds.length) {
await tx.notification.updateMany({
where: { actionData: { in: inviteIds } },
data: { read: true, actionType: null, actionData: null },
})
}
// 3) Invites löschen
await tx.teamInvite.deleteMany({ where: { teamId: team.id } })
// 4) Alle User (inkl. Leader) aus dem Team lösen
await tx.user.updateMany({
where: { teamId: team.id },
data : { teamId: null },
})
// 5) Team löschen
await tx.team.delete({ where: { id: team.id } })
const members = Array.from(new Set(
[team.leaderId, ...(team.activePlayers ?? []), ...(team.inactivePlayers ?? [])]
.filter(Boolean) as string[]
))
return {
inviteeIds: Array.from(new Set(invites.map(i => i.steamId).filter(Boolean))) as string[],
memberIds : members,
}
})
// ---- Nach der Transaktion: SSE & Notifications ----
// a) Ex-Mitglieder informieren + UI refreshen
if (memberIds.length) {
// persistente Notification für alle Ex-Mitglieder
const created = await Promise.all(
memberIds.map(uid =>
prisma.notification.create({
data: {
steamId : uid,
title : 'Team gelöscht',
message : `Das Team „${team.name}“ wurde gelöscht. Du bist nun in keinem Team mehr.`,
actionType: 'user-team-cleared',
actionData: team.id,
},
})
)
)
// live zustellen
await Promise.all(
created.map(n =>
sendServerSSEMessage({
type : 'notification',
targetUserIds: [n.steamId],
message : n.message,
id : n.id,
actionType : n.actionType ?? undefined,
actionData : n.actionData ?? undefined,
createdAt : n.createdAt.toISOString(),
})
)
)
// zusätzliches Soft-Reload-Signal
await sendServerSSEMessage({
type: 'user-team-cleared',
targetUserIds: memberIds,
teamId: team.id,
})
}
// b) Eingeladene (aber nicht beigetretene) Nutzer: Einladung „wegpingen“
if (inviteeIds.length) {
await sendServerSSEMessage({
type: 'team-invite-revoked',
targetUserIds: inviteeIds,
teamId: team.id,
})
}
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
} catch (err) { } catch (err) {
console.error('❌ Fehler beim Löschen des Teams:', err) console.error('❌ Fehler beim Löschen des Teams:', err)

View File

@ -14,7 +14,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 }) return NextResponse.json({ error: 'Fehlende Parameter' }, { status: 400 })
} }
// Team + Member laden (für Zielgruppe) // Team + Members laden (für Notifications)
const teamBefore = await prisma.team.findUnique({ const teamBefore = await prisma.team.findUnique({
where: { id: teamId }, where: { id: teamId },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true }, select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
@ -23,7 +23,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 }) return NextResponse.json({ error: 'Team nicht gefunden' }, { status: 404 })
} }
// umbenennen (Unique-Name beachten) // Umbenennen (Unique-Constraint beachten)
let updated let updated
try { try {
updated = await prisma.team.update({ updated = await prisma.team.update({
@ -38,7 +38,7 @@ export async function POST(req: NextRequest) {
throw e throw e
} }
// Zielnutzer (Leader + aktive + inaktive) // Zielnutzer (Leader + aktive + inaktive) für persistente Notifications
const targets = Array.from(new Set( const targets = Array.from(new Set(
[ [
updated.leaderId, updated.leaderId,
@ -49,7 +49,7 @@ export async function POST(req: NextRequest) {
const text = `Team wurde umbenannt in "${updated.name}".` const text = `Team wurde umbenannt in "${updated.name}".`
// Optional: persistente Notifications (sichtbar im Dropdown + Live via SSE) // Persistente Notifications an Team-Mitglieder + Live-Zustellung (nur an diese Nutzer)
if (targets.length) { if (targets.length) {
const created = await Promise.all( const created = await Promise.all(
targets.map(steamId => targets.map(steamId =>
@ -65,7 +65,6 @@ export async function POST(req: NextRequest) {
) )
) )
// live zustellen als sichtbare Notification
await Promise.all( await Promise.all(
created.map(n => created.map(n =>
sendServerSSEMessage({ sendServerSSEMessage({
@ -81,20 +80,18 @@ export async function POST(req: NextRequest) {
) )
} }
// Team-Event (für SSEHandler → soft reload, keine Broadcasts) // ✅ Globale Team-Events (Broadcast, KEIN targetUserIds) für alle Clients
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-renamed', type: 'team-renamed',
teamId, teamId,
targetUserIds: targets,
message: text, message: text,
newName: updated.name, newName: updated.name,
}) })
// Generisches Reload-Signal (failsafe) // Optionaler Failsafe-Reload als Broadcast
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
teamId, teamId,
targetUserIds: targets,
}) })
return NextResponse.json( return NextResponse.json(

View File

@ -68,6 +68,8 @@ export async function GET(
const rankOld = stats?.rankOld ?? null const rankOld = stats?.rankOld ?? null
const rankNew = stats?.rankNew ?? null const rankNew = stats?.rankNew ?? null
const aim = stats?.aim ?? null
const rankChange = const rankChange =
rankNew != null && rankOld != null ? rankNew - rankOld : null rankNew != null && rankOld != null ? rankNew - rankOld : null
@ -93,6 +95,7 @@ export async function GET(
kills, kills,
deaths, deaths,
kdr, kdr,
aim,
winnerTeam: m.winnerTeam ?? null, winnerTeam: m.winnerTeam ?? null,
team : playerTeam, // „CT“ oder „T“ team : playerTeam, // „CT“ oder „T“

View File

@ -11,60 +11,109 @@ export async function POST(
) { ) {
try { try {
const { action } = params const { action } = params
const { invitationId } = await req.json() const body = await req.json().catch(() => ({} as any))
if (!invitationId) { // NEU: neben invitationId auch teamId+steamId als Fallback akzeptieren
return NextResponse.json({ message: 'Invitation ID fehlt' }, { status: 400 }) const incomingInvitationId: string | undefined = body.invitationId
} const fallbackTeamId: string | undefined = body.teamId
const fallbackSteamId: string | undefined = body.steamId
// Einladung auflösen (bevorzugt per ID, sonst per teamId+steamId)
const invitation =
incomingInvitationId
? await prisma.teamInvite.findUnique({ where: { id: incomingInvitationId } })
: (fallbackTeamId && fallbackSteamId
? await prisma.teamInvite.findFirst({
where: {
type: 'team-invite',
teamId: fallbackTeamId,
steamId: fallbackSteamId,
},
})
: null)
const invitation = await prisma.teamInvite.findUnique({ where: { id: invitationId } })
if (!invitation) { if (!invitation) {
return NextResponse.json({ message: 'Einladung existiert nicht mehr' }, { status: 404 }) return NextResponse.json({ message: 'Einladung nicht gefunden' }, { status: 404 })
} }
const { steamId: invitedUserSteamId, teamId } = invitation if (invitation.type !== 'team-invite') {
return NextResponse.json({ message: 'Ungültiger Einladungstyp' }, { status: 400 })
}
const invitationId = invitation.id
const invitedUserSteamId = invitation.steamId
const teamId = invitation.teamId
if (!teamId) {
return NextResponse.json({ message: 'Einladung ohne Team' }, { status: 400 })
}
/* ───────────────────────────── ACCEPT ───────────────────────────── */
if (action === 'accept') { if (action === 'accept') {
await prisma.user.update({ where: { steamId: invitedUserSteamId }, data: { teamId } }) // Alles in einer Transaktion: User beitreten lassen, invite löschen,
// ALLE anderen offenen team-invite für denselben User entfernen.
const teamBefore = await prisma.team.findUnique({ const result = await prisma.$transaction(async (tx) => {
const teamBefore = await tx.team.findUnique({
where: { id: teamId }, where: { id: teamId },
select: { name: true, leaderId: true, activePlayers: true, inactivePlayers: true }, select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
if (!teamBefore) throw new Error('Team nicht gefunden')
// 1) User ins Team hängen
await tx.user.update({
where: { steamId: invitedUserSteamId },
data: { teamId },
}) })
const nextInactive = Array.from(new Set([...(teamBefore?.inactivePlayers ?? []), invitedUserSteamId])) // 2) Inaktive Liste updaten (ohne Duplikate)
const nextInactive = Array.from(new Set([...(teamBefore.inactivePlayers ?? []), invitedUserSteamId]))
await prisma.team.update({ await tx.team.update({
where: { id: teamId }, where: { id: teamId },
data: { inactivePlayers: nextInactive }, data: { inactivePlayers: nextInactive },
}) })
await prisma.teamInvite.delete({ where: { id: invitationId } }) // 3) Angenommene Einladung löschen + Notifications bereinigen
await prisma.notification.updateMany({ await tx.teamInvite.delete({ where: { id: invitationId } })
await tx.notification.updateMany({
where: { actionData: invitationId }, where: { actionData: invitationId },
data: { read: true, actionType: null, actionData: null }, data: { read: true, actionType: null, actionData: null },
}) })
const team = await prisma.team.findUnique({ // 4) Andere offenen Team-Einladungen für diesen User entfernen
where: { id: teamId }, const otherInvites = await tx.teamInvite.findMany({
select: { name: true, leaderId: true, activePlayers: true, inactivePlayers: true }, where: {
steamId: invitedUserSteamId,
type: 'team-invite',
NOT: { id: invitationId },
},
select: { id: true, teamId: true },
}) })
const allMembers = Array.from( if (otherInvites.length) {
new Set( await tx.teamInvite.deleteMany({ where: { id: { in: otherInvites.map(o => o.id) } } })
[ await tx.notification.updateMany({
team?.leaderId, where: { actionData: { in: otherInvites.map(o => o.id) } },
...(team?.activePlayers ?? []), data: { read: true, actionType: null, actionData: null },
...(team?.inactivePlayers ?? []), })
].filter(Boolean) as string[] }
)
)
return { teamBefore, nextInactive, otherInvites }
})
// Alle Teammitglieder (inkl. neuer Inaktiver)
const allMembers = Array.from(new Set(
[
result.teamBefore.leaderId,
...(result.teamBefore.activePlayers ?? []),
...result.nextInactive,
].filter(Boolean) as string[]
))
// Beitritt-Notification an den Spieler
const joinedNotif = await prisma.notification.create({ const joinedNotif = await prisma.notification.create({
data: { data: {
steamId: invitedUserSteamId, steamId: invitedUserSteamId,
title: 'Teambeitritt', title: 'Teambeitritt',
message: `Du bist dem Team "${team?.name ?? 'Unbekannt'}" beigetreten.`, message: `Du bist dem Team "${result.teamBefore.name ?? 'Unbekannt'}" beigetreten.`,
actionType: 'team-joined', actionType: 'team-joined',
actionData: teamId, actionData: teamId,
}, },
@ -79,12 +128,13 @@ export async function POST(
createdAt: joinedNotif.createdAt.toISOString(), createdAt: joinedNotif.createdAt.toISOString(),
}) })
// Info an die übrigen Teammitglieder
const others = allMembers.filter(id => id !== invitedUserSteamId)
if (others.length) {
const joiningUser = await prisma.user.findUnique({ const joiningUser = await prisma.user.findUnique({
where: { steamId: invitedUserSteamId }, where: { steamId: invitedUserSteamId },
select: { name: true }, select: { name: true },
}) })
const others = allMembers.filter(id => id !== invitedUserSteamId)
if (others.length) {
const created = await Promise.all( const created = await Promise.all(
others.map(uid => others.map(uid =>
prisma.notification.create({ prisma.notification.create({
@ -113,41 +163,76 @@ export async function POST(
) )
} }
// Soft-Reload fürs beigetretene Team
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
teamId, teamId,
targetUserIds: allMembers, targetUserIds: allMembers,
}) })
// Übrige Einladungen bei anderen Teams per SSE „revoken“
if (result.otherInvites.length) {
const byTeam = new Map<string, string[]>()
for (const o of result.otherInvites) {
if (!o.teamId) continue
byTeam.set(o.teamId, [...(byTeam.get(o.teamId) ?? []), o.id])
}
const otherTeams = await prisma.team.findMany({
where: { id: { in: Array.from(byTeam.keys()) } },
select: { id: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
for (const t of otherTeams) {
const recipients = Array.from(new Set(
[t.leaderId, ...(t.activePlayers ?? []), ...(t.inactivePlayers ?? [])].filter(Boolean) as string[]
))
const inviteIds = byTeam.get(t.id) ?? []
// pro gelöschter Einladung ein Event
await Promise.all(inviteIds.map(invId =>
sendServerSSEMessage({
type: 'team-invite-revoked',
targetUserIds: recipients,
invitationId: invId,
teamId: t.id,
})
))
if (recipients.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId: t.id,
targetUserIds: recipients,
})
}
}
}
return NextResponse.json({ message: 'Einladung angenommen' }) return NextResponse.json({ message: 'Einladung angenommen' })
} }
/* ───────────────────────────── REJECT ──────────────────────────── */
if (action === 'reject') { if (action === 'reject') {
// Einladung löschen & zugehörige Notifications aufräumen (keine sichtbare Nachricht)
await prisma.teamInvite.delete({ where: { id: invitationId } }) await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({ await prisma.notification.updateMany({
where: { actionData: invitationId }, where: { actionData: invitationId },
data: { read: true, actionType: null, actionData: null }, data: { read: true, actionType: null, actionData: null },
}) })
// ➜ Team-Mitglieder ermitteln (Leader + aktive + inaktive), ohne die eingeladene Person
const team = await prisma.team.findUnique({ const team = await prisma.team.findUnique({
where: { id: teamId }, where: { id: teamId },
select: { leaderId: true, activePlayers: true, inactivePlayers: true }, select: { leaderId: true, activePlayers: true, inactivePlayers: true },
}) })
const remainingMembers = Array.from( const remainingMembers = Array.from(new Set(
new Set(
[ [
team?.leaderId, team?.leaderId,
...(team?.activePlayers ?? []), ...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []), ...(team?.inactivePlayers ?? []),
] ].filter(Boolean) as string[]
.filter(Boolean) as string[] )).filter(id => id !== invitedUserSteamId)
)
).filter(id => id !== invitedUserSteamId)
// ➜ Silent UI Refresh via SSE für die verbleibenden Mitglieder
if (remainingMembers.length) { if (remainingMembers.length) {
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
@ -159,6 +244,7 @@ export async function POST(
return NextResponse.json({ message: 'Einladung abgelehnt' }) return NextResponse.json({ message: 'Einladung abgelehnt' })
} }
/* ───────────────────────────── REVOKE ──────────────────────────── */
if (action === 'revoke') { if (action === 'revoke') {
await prisma.teamInvite.delete({ where: { id: invitationId } }) await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({ await prisma.notification.updateMany({
@ -170,28 +256,34 @@ export async function POST(
where: { id: teamId }, where: { id: teamId },
select: { leaderId: true, activePlayers: true, inactivePlayers: true }, select: { leaderId: true, activePlayers: true, inactivePlayers: true },
}) })
const admins = await prisma.user.findMany({ const admins = await prisma.user.findMany({
where: { isAdmin: true }, where: { isAdmin: true },
select: { steamId: true }, select: { steamId: true },
}) })
const targetUserIds = Array.from( const targetUserIds = Array.from(new Set([
new Set(
[
team?.leaderId, team?.leaderId,
...(team?.activePlayers ?? []), ...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []), ...(team?.inactivePlayers ?? []),
...admins.map(a => a.steamId), ...admins.map(a => a.steamId),
].filter(Boolean) as string[] ].filter(Boolean) as string[]))
)
)
// Eingeladenen informieren, damit die Einladung sofort verschwindet
await sendServerSSEMessage({
type: 'team-invite-revoked',
targetUserIds: [invitedUserSteamId],
invitationId,
teamId,
})
// Team-UI refreshen
if (targetUserIds.length) {
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'team-updated', type: 'team-updated',
teamId, teamId,
targetUserIds, targetUserIds,
}) })
}
return NextResponse.json({ message: 'Einladung gelöscht' }) return NextResponse.json({ message: 'Einladung gelöscht' })
} }

View File

@ -1,3 +1,5 @@
// src/app/api/user/route.ts
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
@ -18,6 +20,7 @@ export async function GET(req: NextRequest) {
steamId: true, steamId: true,
avatar: true, avatar: true,
team: true, team: true,
premierRank: true,
isAdmin: true, isAdmin: true,
}, },
}) })

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
@ -9,23 +9,37 @@ import { format } from 'date-fns'
import { de } from 'date-fns/locale' import { de } from 'date-fns/locale'
import Switch from '@/app/components/Switch' import Switch from '@/app/components/Switch'
import Button from './Button' import Button from './Button'
import Modal from './Modal'
import { Match } from '../types/match' import { Match } from '../types/match'
import { differenceInMinutes } from 'date-fns'
type Props = { matchType?: string } type Props = { matchType?: string }
/* ------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------ */
const getTeamLogo = (logo?: string | null) => const getTeamLogo = (logo?: string | null) =>
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp' logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
const toDateKey = (d: Date) => d.toISOString().slice(0, 10) const toDateKey = (d: Date) => d.toISOString().slice(0, 10)
const weekdayDE = new Intl.DateTimeFormat('de-DE', { weekday: 'long' }) const weekdayDE = new Intl.DateTimeFormat('de-DE', { weekday: 'long' })
/* ------------------------------------------------------------ */ type TeamOption = { id: string; name: string; logo?: string | null }
/* Component */
/* ------------------------------------------------------------ */ /** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */
function combineLocalDateTime(dateStr: string, timeStr: string) {
const [y, m, d] = dateStr.split('-').map(Number)
const [hh, mm] = timeStr.split(':').map(Number)
const dt = new Date(y, (m - 1), d, hh, mm, 0, 0) // lokale Zeit
return dt.toISOString()
}
/** nächste volle Stunde als Default */
function getNextHourDefaults() {
const now = new Date()
now.setMinutes(0, 0, 0)
now.setHours(now.getHours() + 1)
const dateStr = now.toISOString().slice(0, 10)
const timeStr = now.toTimeString().slice(0, 5) // HH:MM
return { dateStr, timeStr }
}
export default function CommunityMatchList({ matchType }: Props) { export default function CommunityMatchList({ matchType }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
@ -33,34 +47,141 @@ export default function CommunityMatchList({ matchType }: Props) {
const [matches, setMatches] = useState<Match[]>([]) const [matches, setMatches] = useState<Match[]>([])
const [onlyOwn, setOnlyOwn] = useState(false) const [onlyOwn, setOnlyOwn] = useState(false)
/* Daten laden */ // Modal-States
useEffect(() => { const [showCreate, setShowCreate] = useState(false)
const url = `/api/matches${matchType ? `?type=${encodeURIComponent(matchType)}` : ''}` const [teams, setTeams] = useState<TeamOption[]>([])
const [loadingTeams, setLoadingTeams] = useState(false)
const [saving, setSaving] = useState(false)
fetch(url) const [teamAId, setTeamAId] = useState<string>('')
.then(r => (r.ok ? r.json() : [])) const [teamBId, setTeamBId] = useState<string>('')
.then(setMatches) const [title, setTitle] = useState<string>('') // auto editierbar
.catch(err => console.error('[MatchList] Laden fehlgeschlagen:', err)) const [autoTitle, setAutoTitle] = useState(true)
const [bestOf, setBestOf] = useState<3 | 5>(3)
// Datum & Uhrzeit
const defaults = getNextHourDefaults()
const [matchDateStr, setMatchDateStr] = useState<string>(defaults.dateStr) // YYYY-MM-DD
const [matchTimeStr, setMatchTimeStr] = useState<string>(defaults.timeStr) // HH:MM
const teamById = useCallback(
(id?: string) => teams.find(t => t.id === id),
[teams]
)
// Auto-Titel
useEffect(() => {
if (!autoTitle) return
const a = teamById(teamAId)?.name ?? 'Team A'
const b = teamById(teamBId)?.name ?? 'Team B'
setTitle(`${a} vs ${b}`)
}, [teamAId, teamBId, autoTitle, teamById])
// Matches laden
const loadMatches = useCallback(async () => {
const url = `/api/matches${matchType ? `?type=${encodeURIComponent(matchType)}` : ''}`
try {
const r = await fetch(url, { cache: 'no-store' })
const data = r.ok ? await r.json() : []
setMatches(data)
} catch (err) {
console.error('[MatchList] Laden fehlgeschlagen:', err)
}
}, [matchType]) }, [matchType])
/* Sortieren + Gruppieren (ohne vorher zu filtern!) */ useEffect(() => { loadMatches() }, [loadMatches])
// Teams laden, wenn Modal aufgeht
useEffect(() => {
if (!showCreate || teams.length) return
;(async () => {
setLoadingTeams(true)
try {
const res = await fetch('/api/team/list', { cache: 'no-store' })
const json = await res.json()
const opts: TeamOption[] = (json.teams ?? []).map((t: any) => ({
id: t.id, name: t.name, logo: t.logo,
}))
setTeams(opts)
} catch (e) {
console.error('[MatchList] /api/team/list fehlgeschlagen:', e)
setTeams([])
} finally {
setLoadingTeams(false)
}
})()
}, [showCreate, teams.length])
const resetCreateState = () => {
setTeamAId('')
setTeamBId('')
setTitle('')
setAutoTitle(true)
setBestOf(3)
const d = getNextHourDefaults()
setMatchDateStr(d.dateStr)
setMatchTimeStr(d.timeStr)
}
const canSave =
!saving &&
teamAId &&
teamBId &&
teamAId !== teamBId &&
title.trim().length > 0 &&
(bestOf === 3 || bestOf === 5) &&
!!matchDateStr &&
!!matchTimeStr
const handleCreate = async () => {
if (!canSave) return
setSaving(true)
try {
const matchDateISO = combineLocalDateTime(matchDateStr, matchTimeStr)
const res = await fetch('/api/matches/create', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
teamAId,
teamBId,
title: title.trim(),
bestOf, // 3 | 5
matchDate: matchDateISO, // <- Datum+Zeit
type : matchType, // optional
}),
})
if (!res.ok) {
const j = await res.json().catch(() => ({}))
alert(j.message ?? 'Erstellen fehlgeschlagen')
return
}
setShowCreate(false)
resetCreateState()
await loadMatches()
} catch (e) {
console.error('[MatchList] Match erstellen fehlgeschlagen:', e)
alert('Match konnte nicht erstellt werden.')
} finally {
setSaving(false)
}
}
// Gruppieren
const grouped = (() => { const grouped = (() => {
const sorted = [...matches].sort( const sorted = [...matches].sort(
(a, b) => new Date(a.demoDate).getTime() - new Date(b.demoDate).getTime(), (a, b) => new Date(a.demoDate).getTime() - new Date(b.demoDate).getTime(),
) )
const map = new Map<string, Match[]>() const map = new Map<string, Match[]>()
for (const m of sorted) { for (const m of sorted) {
const key = toDateKey(new Date(m.demoDate)) const key = toDateKey(new Date(m.demoDate))
map.set(key, [...(map.get(key) ?? []), m]) map.set(key, [...(map.get(key) ?? []), m])
} }
return Array.from(map.entries()) // [ [ '2025-08-28', [ … ] ], … ] return Array.from(map.entries())
})() })()
/* Render */
return ( return (
<div className="max-w-7xl mx-auto py-8 px-4 space-y-6"> <div className="max-w-7xl mx-auto py-8 px-4 space-y-6">
{/* Kopfzeile ----------------------------------------------------- */} {/* Kopfzeile */}
<div className="flex items-center justify-between flex-wrap gap-y-4"> <div className="flex items-center justify-between flex-wrap gap-y-4">
<h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300"> <h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300">
Geplante Matches Geplante Matches
@ -72,16 +193,15 @@ export default function CommunityMatchList({ matchType }: Props) {
onChange={setOnlyOwn} onChange={setOnlyOwn}
labelRight="Nur mein Team anzeigen" labelRight="Nur mein Team anzeigen"
/> />
{session?.user?.isAdmin && ( {session?.user?.isAdmin && (
<Link href="/admin/matches"> <Button color="blue" onClick={() => setShowCreate(true)}>
<Button color="blue" onClick={() => router.push(`/admin/matches`)}>Match erstellen</Button> Match erstellen
</Link> </Button>
)} )}
</div> </div>
</div> </div>
{/* Inhalt ------------------------------------------------------- */} {/* Inhalt */}
{grouped.length === 0 ? ( {grouped.length === 0 ? (
<p className="text-gray-700 dark:text-neutral-300">Keine Matches geplant.</p> <p className="text-gray-700 dark:text-neutral-300">Keine Matches geplant.</p>
) : ( ) : (
@ -89,96 +209,55 @@ export default function CommunityMatchList({ matchType }: Props) {
{grouped.map(([dateKey, dayMatches], dayIdx) => { {grouped.map(([dateKey, dayMatches], dayIdx) => {
const dateObj = new Date(dateKey + 'T00:00:00') const dateObj = new Date(dateKey + 'T00:00:00')
const dayLabel = `Tag #${dayIdx + 1} ${weekdayDE.format(dateObj)}` const dayLabel = `Tag #${dayIdx + 1} ${weekdayDE.format(dateObj)}`
return ( return (
<div key={dateKey} className="flex flex-col gap-4"> <div key={dateKey} className="flex flex-col gap-4">
{/* Tages-Header */}
<div className="bg-yellow-300 dark:bg-yellow-500 text-center py-2 font-bold tracking-wider"> <div className="bg-yellow-300 dark:bg-yellow-500 text-center py-2 font-bold tracking-wider">
{dayLabel}<br /> {dayLabel}<br />{dateKey}
{dateKey}
</div> </div>
{/* Matches des Tages */}
{dayMatches.map(m => { {dayMatches.map(m => {
/* 1⃣ Regeln --------------------------------------------- */ const started = new Date(m.demoDate).getTime() <= Date.now()
const demoDate = new Date(m.demoDate)
const started = demoDate <= Date.now()
const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null
const isLive = started && unfinished // ← live-Flag const isLive = started && unfinished
const isOwnTeam = !!session?.user?.team &&
const isOwnTeam =
session?.user?.team &&
(m.teamA.id === session.user.team || m.teamB.id === session.user.team) (m.teamA.id === session.user.team || m.teamB.id === session.user.team)
/* Wenn nur-Own aktiv & nicht eigenes Match → abdunkeln */
const dimmed = onlyOwn && !isOwnTeam const dimmed = onlyOwn && !isOwnTeam
return ( return (
<Link <Link
key={m.id} key={m.id}
href={`/match-details/${m.id}`} href={`/match-details/${m.id}`}
className={` className={`
flex flex-col items-center gap-4 bg-neutral-300 dark:bg-neutral-800 relative flex flex-col items-center gap-4 bg-neutral-300 dark:bg-neutral-800
text-gray-800 dark:text-white rounded-sm py-4 text-gray-800 dark:text-white rounded-sm py-4
hover:scale-105 hover:bg-neutral-400 hover:dark:bg-neutral-700 hover:scale-105 hover:bg-neutral-400 hover:dark:bg-neutral-700
hover:shadow-md transition-transform h-[172px] hover:shadow-md h-[172px]
${dimmed ? 'opacity-40' : ''} transition-transform transition-opacity duration-300 ease-in-out
${dimmed ? 'opacity-40' : 'opacity-100'}
`} `}
> >
{/** ⏱ kleine Live-Marke, falls gewünscht */}
{isLive && ( {isLive && (
<span className="absolute px-2 py-0.5 text-xs font-semibold rounded-full bg-red-600 text-white"> <span className="absolute top-2 px-2 py-0.5 text-xs font-semibold rounded-full bg-red-300 dark:bg-red-500 text-white">
LIVE LIVE
</span> </span>
)} )}
{/* Teams -------------------------------------------------- */}
<div className="flex w-full justify-around items-center"> <div className="flex w-full justify-around items-center">
{/* Team A */}
<div className="flex flex-col items-center w-1/3"> <div className="flex flex-col items-center w-1/3">
<Image <Image src={getTeamLogo(m.teamA.logo)} alt={m.teamA.name} width={48} height={48} className="rounded-full border bg-white" />
src={getTeamLogo(m.teamA.logo)}
alt={m.teamA.name}
width={48}
height={48}
className="rounded-full border bg-white"
/>
<span className="mt-2 text-xs">{m.teamA.name}</span> <span className="mt-2 text-xs">{m.teamA.name}</span>
</div> </div>
{/* vs */}
<span className="font-bold">vs</span> <span className="font-bold">vs</span>
{/* Team B */}
<div className="flex flex-col items-center w-1/3"> <div className="flex flex-col items-center w-1/3">
<Image <Image src={getTeamLogo(m.teamB.logo)} alt={m.teamB.name} width={48} height={48} className="rounded-full border bg-white" />
src={getTeamLogo(m.teamB.logo)}
alt={m.teamB.name}
width={48}
height={48}
className="rounded-full border bg-white"
/>
<span className="mt-2 text-xs">{m.teamB.name}</span> <span className="mt-2 text-xs">{m.teamB.name}</span>
</div> </div>
</div> </div>
{/* Datum + Uhrzeit --------------------------------------- */}
<div className="flex flex-col items-center space-y-1 mt-2"> <div className="flex flex-col items-center space-y-1 mt-2">
{/* Datum */}
<span className={`px-3 py-0.5 rounded-full text-sm font-semibold ${isLive ? 'bg-red-300 dark:bg-red-500' : 'bg-yellow-300 dark:bg-yellow-500'}`}> <span className={`px-3 py-0.5 rounded-full text-sm font-semibold ${isLive ? 'bg-red-300 dark:bg-red-500' : 'bg-yellow-300 dark:bg-yellow-500'}`}>
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })} {format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
</span> </span>
{/* Zeit */}
<span className="flex items-center gap-1 text-xs opacity-80"> <span className="flex items-center gap-1 text-xs opacity-80">
<svg <svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 512 512">
xmlns="http://www.w3.org/2000/svg"
className="w-3.5 h-3.5"
fill="currentColor"
viewBox="0 0 512 512"
>
<path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" /> <path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" />
</svg> </svg>
{format(new Date(m.demoDate), 'HH:mm', { locale: de })} Uhr {format(new Date(m.demoDate), 'HH:mm', { locale: de })} Uhr
@ -192,6 +271,127 @@ export default function CommunityMatchList({ matchType }: Props) {
})} })}
</div> </div>
)} )}
{/* Modal: Match erstellen */}
<Modal
id="create-match-modal"
title="Match erstellen"
show={showCreate}
onClose={() => { setShowCreate(false); resetCreateState() }}
onSave={handleCreate}
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
closeButtonColor="blue"
disableCloseButton={!canSave}
>
<div className="space-y-4">
{/* Team A */}
<label className="block text-sm font-medium mb-1">Team A</label>
<select
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
value={teamAId}
onChange={(e) => setTeamAId(e.target.value)}
disabled={loadingTeams}
>
<option value=""> bitte wählen </option>
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{/* Team B */}
<label className="block text-sm font-medium mb-1 mt-2">Team B</label>
<select
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
value={teamBId}
onChange={(e) => setTeamBId(e.target.value)}
disabled={loadingTeams}
>
<option value=""> bitte wählen </option>
{teams.map(t => (
<option key={t.id} value={t.id} disabled={t.id === teamAId}>
{t.name}
</option>
))}
</select>
{/* Titel */}
<div className="mt-3">
<label className="block text-sm font-medium mb-1">
Titel {autoTitle && <span className="ml-2 text-xs text-gray-500">(automatisch)</span>}
</label>
<input
type="text"
value={title}
onChange={(e) => { setTitle(e.target.value); setAutoTitle(false) }}
onFocus={() => setAutoTitle(false)}
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
placeholder="Team A vs Team B"
/>
{!autoTitle && (
<button
type="button"
className="mt-1 text-xs text-blue-600 hover:underline"
onClick={() => setAutoTitle(true)}
>
Titel wieder automatisch generieren
</button>
)}
</div>
{/* Datum & Uhrzeit */}
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<label className="block text-sm font-medium mb-1">Datum</label>
<input
type="date"
value={matchDateStr}
min={new Date().toISOString().slice(0,10)}
onChange={(e) => setMatchDateStr(e.target.value)}
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Uhrzeit</label>
<input
type="time"
value={matchTimeStr}
onChange={(e) => setMatchTimeStr(e.target.value)}
step={300} // 5-Minuten-Schritte
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
/>
</div>
</div>
<p className="text-[11px] text-gray-500 dark:text-neutral-400">
Die Uhrzeit wird als lokale Zeit gespeichert.
</p>
{/* Best-of */}
<div className="mt-3">
<label className="block text-sm font-medium mb-1">Modus</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setBestOf(3)}
className={`px-3 py-1.5 rounded-lg text-sm border ${bestOf === 3 ? 'bg-blue-600 text-white border-blue-600' : 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
BO3
</button>
<button
type="button"
onClick={() => setBestOf(5)}
className={`px-3 py-1.5 rounded-lg text-sm border ${bestOf === 5 ? 'bg-blue-600 text-white border-blue-600' : 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
BO5
</button>
</div>
</div>
{loadingTeams && (
<p className="text-sm text-gray-500 mt-2">Teams werden geladen </p>
)}
{teamAId && teamBId && teamAId === teamBId && (
<p className="text-sm text-red-600 mt-2">Bitte zwei unterschiedliche Teams wählen.</p>
)}
</div>
</Modal>
</div> </div>
) )
} }

View File

@ -4,7 +4,6 @@ import { useEffect, useState, useImperativeHandle, forwardRef } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Modal from './Modal' import Modal from './Modal'
import Button from './Button' import Button from './Button'
import { Player, Team } from '../types/team'
type CreateTeamButtonProps = { type CreateTeamButtonProps = {
setRefetchKey: (key: string) => void setRefetchKey: (key: string) => void
@ -18,6 +17,32 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
// ⬇️ Helfer: Modal schließen + Backdrop sicher entfernen
const closeCreateModalAndCleanup = () => {
const modalEl = document.getElementById('modal-create-team')
// 1) HSOverlay schließen (falls vorhanden)
if (modalEl && (window as any).HSOverlay?.close) {
;(window as any).HSOverlay.close(modalEl)
}
// 2) React-State schließen (sorgt dafür, dass unser Modal unmounted)
setShowModal(false)
// 3) HARTE Aufräumaktion: evtl. übrig gebliebene Backdrops/Overlays deaktivieren/entfernen
requestAnimationFrame(() => {
// alle Backdrops entfernen
document.querySelectorAll('.hs-overlay-backdrop').forEach(el => el.remove())
// sicherheitshalber versteckte Overlays un-klickbar machen
document.querySelectorAll('.hs-overlay[aria-hidden="true"]').forEach(el => {
(el as HTMLElement).style.pointerEvents = 'none'
;(el as HTMLElement).style.display = 'none'
})
// falls HSOverlay Klassen am Body gesetzt hat
document.body.classList.remove('overflow-hidden')
})
}
const handleSubmit = async () => { const handleSubmit = async () => {
setStatus('idle') setStatus('idle')
setMessage('') setMessage('')
@ -36,25 +61,18 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
}) })
const result = await res.json() const result = await res.json()
if (!res.ok) throw new Error(result.message || 'Fehler beim Erstellen')
if (!res.ok) {
throw new Error(result.message || 'Fehler beim Erstellen')
}
setStatus('success') setStatus('success')
setMessage(`Team "${result.team.name}" wurde erfolgreich erstellt!`) setMessage(`Team "${result.team.name}" wurde erfolgreich erstellt!`)
setTeamname('') setTeamname('')
// 🔒 Nach kurzer Bestätigung Modal schließen, Backdrop bereinigen und Liste refreshen
setTimeout(() => { setTimeout(() => {
const modalEl = document.getElementById('modal-create-team') closeCreateModalAndCleanup()
if (modalEl && window.HSOverlay?.close) { // einen Tick warten, bis DOM frei ist
window.HSOverlay.close(modalEl) requestAnimationFrame(() => setRefetchKey(Date.now().toString()))
} }, 800) // kürzer reicht idR, 800ms für Feedback
setShowModal(false)
setRefetchKey(Date.now().toString())
}, 1500)
} catch (err: any) { } catch (err: any) {
setStatus('error') setStatus('error')
setMessage(err.message || 'Fehler beim Erstellen des Teams') setMessage(err.message || 'Fehler beim Erstellen des Teams')
@ -63,12 +81,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
return ( return (
<div> <div>
<Button <Button onClick={() => setShowModal(true)} color="blue" variant="solid" size="sm">
onClick={() => setShowModal(true)}
color="blue"
variant="solid"
size="sm"
>
Neues Team erstellen Neues Team erstellen
</Button> </Button>
@ -76,7 +89,11 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
id="modal-create-team" id="modal-create-team"
title="Neues Team erstellen" title="Neues Team erstellen"
show={showModal} show={showModal}
onClose={() => setShowModal(false)} onClose={() => {
setShowModal(false)
// Falls Benutzer per X schließt auch dann cleanen
closeCreateModalAndCleanup()
}}
onSave={handleSubmit} onSave={handleSubmit}
closeButtonTitle="Team erstellen" closeButtonTitle="Team erstellen"
> >
@ -95,14 +112,14 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
setMessage('') setMessage('')
}} }}
placeholder="Gebe einen Teamnamen ein..." placeholder="Gebe einen Teamnamen ein..."
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 className={`
${ py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
status === 'error' 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' ? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: status === 'success' : status === 'success'
? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500' ? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'}
}
`} `}
required required
name="teamname" name="teamname"
@ -113,10 +130,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
<div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none"> <div className="absolute inset-y-0 end-0 flex items-center pe-3 pointer-events-none">
<svg <svg
className={`shrink-0 size-4 ${status === 'error' ? 'text-red-500' : 'text-teal-500'}`} className={`shrink-0 size-4 ${status === 'error' ? 'text-red-500' : 'text-teal-500'}`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
> >
{status === 'error' ? ( {status === 'error' ? (
<> <>
@ -134,9 +148,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
{message && ( {message && (
<p <p
id="teamname-feedback" id="teamname-feedback"
className={`text-sm mt-1 ${ className={`text-sm mt-1 ${status === 'error' ? 'text-red-600' : 'text-teal-600'}`}
status === 'error' ? 'text-red-600' : 'text-teal-600'
}`}
> >
{message} {message}
</p> </p>
@ -148,5 +160,4 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
}) })
CreateTeamButton.displayName = 'CreateTeamButton' CreateTeamButton.displayName = 'CreateTeamButton'
export default CreateTeamButton export default CreateTeamButton

View File

@ -19,7 +19,7 @@ import SortableMiniCard from '@/app/components/SortableMiniCard'
import LoadingSpinner from '@/app/components/LoadingSpinner' import LoadingSpinner from '@/app/components/LoadingSpinner'
import { DroppableZone } from '@/app/components/DroppableZone' import { DroppableZone } from '@/app/components/DroppableZone'
import type { Player, Team } from '@/app/types/team' import type { Player, Team, TeamMatches } from '@/app/types/team'
/* ───────────────────────── Typen ────────────────────────── */ /* ───────────────────────── Typen ────────────────────────── */
export type EditSide = 'A' | 'B' export type EditSide = 'A' | 'B'
@ -28,8 +28,8 @@ interface Props {
show : boolean show : boolean
onClose : () => void onClose : () => void
matchId : string matchId : string
teamA : Team teamA : TeamMatches
teamB : Team teamB : TeamMatches
side : EditSide // welches Team wird editiert? side : EditSide // welches Team wird editiert?
initialA: string[] // bereits eingesetzte Spieler-IDs initialA: string[] // bereits eingesetzte Spieler-IDs
initialB: string[] initialB: string[]

View File

@ -42,11 +42,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const fetchUsersNotInTeam = async () => { const fetchUsersNotInTeam = async () => {
try { try {
setIsLoading(true) setIsLoading(true)
const res = await fetch('/api/team/available-users') const res = await fetch(`/api/team/available-users?teamId=${encodeURIComponent(team.id)}`)
const data = await res.json() const data = await res.json()
setAllUsers(data.users || []) setAllUsers(data.users || [])
} catch (err) {
console.error('Fehler beim Laden der Benutzer:', err)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }

View File

@ -74,7 +74,7 @@ export function MatchDetails ({ match }: { match: Match }) {
<ColGroup /> <ColGroup />
<Table.Head> <Table.Head>
<Table.Row> <Table.Row>
{['Spieler','Rank','K','A','D','1K','2K','3K','4K','5K', {['Spieler','Rank','Aim','K','A','D','1K','2K','3K','4K','5K',
'K/D','ADR','HS%','Damage'].map(h => ( 'K/D','ADR','HS%','Damage'].map(h => (
<Table.Cell key={h} as="th">{h}</Table.Cell> <Table.Cell key={h} as="th">{h}</Table.Cell>
))} ))}
@ -114,14 +114,19 @@ export function MatchDetails ({ match }: { match: Match }) {
</div> </div>
</Table.Cell> </Table.Cell>
<Table.Cell>
{Number.isFinite(Number(p.stats?.aim))
? `${Number(p.stats?.aim).toFixed(0)} %`
: '-'}
</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.oneK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.twoK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.threeK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.fourK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k5 ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.fiveK ?? '-'}</Table.Cell>
<Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell> <Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell> <Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell> <Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>

View File

@ -4,7 +4,6 @@
import Button from './Button' import Button from './Button'
import Image from 'next/image' import Image from 'next/image'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
import { revokeInvitation } from '../lib/sse-actions'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
type MiniCardProps = { type MiniCardProps = {
@ -77,9 +76,7 @@ export default function MiniCard({
const handleRevokeClick = (e: React.MouseEvent) => { const handleRevokeClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (invitationId) { onKick?.(steamId)
revokeInvitation(invitationId)
}
} }
const handleKickClick = (e: React.MouseEvent) => { const handleKickClick = (e: React.MouseEvent) => {

View File

@ -5,7 +5,7 @@ import NotificationDropdown from './NotificationDropdown'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import { NOTIFICATION_EVENTS } from '../lib/sseEvents' import { NOTIFICATION_EVENTS, isSseEventType } from '../lib/sseEvents'
type Notification = { type Notification = {
id: string id: string
@ -42,6 +42,36 @@ export default function NotificationCenter() {
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const [animateBell, setAnimateBell] = useState(false) const [animateBell, setAnimateBell] = useState(false)
useEffect(() => {
if (!lastEvent) return
if (!isSseEventType(lastEvent.type)) return
const data = lastEvent.payload
// ⬅️ Einladung zurückgezogen: betroffene Notifications entfernen und abbrechen
if (lastEvent.type === 'team-invite-revoked') {
const invId = data?.invitationId as string | undefined
const teamId = data?.teamId as string | undefined
setNotifications(prev =>
prev.filter(n => {
const isInvite = n.actionType === 'team-invite' || n.actionType === 'invitation'
if (!isInvite) return true
if (invId) return n.actionData !== invId && n.id !== invId
if (teamId) return n.actionData !== teamId
return true
})
)
return
}
// Nur Events, die wir als sichtbare Notifications zeigen wollen
if (!NOTIFICATION_EVENTS.has(lastEvent.type)) return
if (data?.type === 'heartbeat') return
const msg = (data?.message ?? '').trim()
if (!msg) return
}, [lastEvent])
// 1) Initial laden // 1) Initial laden
useEffect(() => { useEffect(() => {
const steamId = session?.user?.steamId const steamId = session?.user?.steamId

View File

@ -1,48 +1,49 @@
'use client' 'use client'
import { signIn, signOut } from 'next-auth/react'
import { useSteamProfile } from '@/app/hooks/useSteamProfile'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSession, signIn, signOut } from 'next-auth/react'
import { useRouter, usePathname } from 'next/navigation'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { usePathname } from 'next/navigation'
import Script from "next/script";
import LoadingSpinner from '@/app/components/LoadingSpinner'
import Image from 'next/image' import Image from 'next/image'
import LoadingSpinner from '@/app/components/LoadingSpinner'
import Button from './Button' import Button from './Button'
import PremierRankBadge from './PremierRankBadge'
export default function SidebarFooter() { export default function SidebarFooter() {
const router = useRouter() const router = useRouter()
const { session, steamProfile, status } = useSteamProfile()
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname() const pathname = usePathname()
const [teamName, setTeamName] = useState<string | null>(null) const { data: session, status } = useSession()
const [isOpen, setIsOpen] = useState(false)
const [teamName, setTeamName] = useState<string | null>(null)
const [premierRank, setPremierRank] = useState<number>(0)
// ➜ Nach Login: User aus DB laden (inkl. premierRank & Teamname)
useEffect(() => { useEffect(() => {
const loadTeamName = async () => { if (status !== 'authenticated') {
const teamId = session?.user?.team
if (!teamId) {
setTeamName(null) setTeamName(null)
setPremierRank(0) // ← immer 0, nicht null
return return
} }
(async () => {
try { try {
const res = await fetch(`/api/team/${teamId}`) const res = await fetch('/api/user', { cache: 'no-store' })
const data = await res.json() if (!res.ok) return
setTeamName(data?.teamname ?? null) const user = await res.json()
} catch (err) { const rank = typeof user?.premierRank === 'number' ? user.premierRank : 0
console.error('[SidebarFooter] TeamName konnte nicht geladen werden:', err) setPremierRank(rank)
setTeamName(null) setTeamName(user?.team?.name ?? null)
} catch (e) {
console.error('[SidebarFooter] /api/user fehlgeschlagen:', e)
setPremierRank(0)
} }
} })()
}, [status])
loadTeamName()
}, [session?.user?.team])
if (status === 'loading') return <LoadingSpinner /> if (status === 'loading') return <LoadingSpinner />
if (status === 'unauthenticated') { if (status === 'unauthenticated') {
return ( return (
<>
<button <button
onClick={() => signIn('steam')} onClick={() => signIn('steam')}
className="flex items-center justify-center gap-2 w-full py-4 px-6 bg-green-800 text-white text-md font-medium hover:bg-green-900 transition" className="flex items-center justify-center gap-2 w-full py-4 px-6 bg-green-800 text-white text-md font-medium hover:bg-green-900 transition"
@ -50,48 +51,58 @@ export default function SidebarFooter() {
<i className="fab fa-steam" /> <i className="fab fa-steam" />
<span>Mit Steam anmelden</span> <span>Mit Steam anmelden</span>
</button> </button>
</>
) )
} }
const user = session!.user const subline = teamName ?? session?.user?.steamId
const userName = session?.user?.name || 'Profil'
const avatarSrc = (session?.user as any)?.avatar || session?.user?.image || '/default-avatar.png'
const subline = const linkClass = (active: boolean) =>
teamName // 1. Teamname, wenn vorhanden `flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors ${
?? steamProfile?.steamId // 2. SteamID (wenn bereits vom Hook gemappt) active
?? user.id // 3. Fallback auf JWTid ? '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'
}`
return ( return (
<>
<div className="relative w-full"> <div className="relative w-full">
{/* Button */} {/* Kopf / Toggle */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(v => !v)}
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 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'} ${isOpen ? 'bg-gray-100 dark:bg-neutral-700' : 'hover:bg-gray-100 dark:hover:bg-neutral-700'}
`} `}
> >
<div className="shrink-0 group block"> {/* Linker Block: Avatar + Name/Subline + Rank */}
<div className="flex items-center"> <div className="flex items-center flex-1 min-w-0">
<Image <Image
src={steamProfile?.avatarfull || user?.image || '/default-avatar.png'} src={avatarSrc}
quality={75} quality={75}
width={40} width={40}
height={40} height={40}
className="inline-block shrink-0 size-10 rounded-full" className="inline-block size-10 rounded-full shrink-0"
draggable={false} draggable={false}
alt="Avatar" alt="Avatar"
/> />
<div className="ms-3"> <div className="ms-3 flex-1 min-w-0">
<h3 className="font-semibold text-gray-800 dark:text-white">{steamProfile?.personaname || user?.name}</h3> <h3 className="font-semibold text-gray-800 dark:text-white truncate">
<p className="text-xs font-medium text-gray-400 dark:text-neutral-500">{subline}</p> {userName}
</div> </h3>
<p className="text-xs font-medium text-gray-400 dark:text-neutral-500 truncate">
{subline}
</p>
</div>
{/* Badge darf nicht schrumpfen */}
<div className="ml-2 flex-shrink-0">
<PremierRankBadge rank={premierRank} />
</div> </div>
</div> </div>
{/* Pfeil ebenfalls nicht schrumpfen */}
<svg <svg
className={`ms-auto size-4 group-hover:text-gray-500 ${ className={`ms-2 size-4 shrink-0 ${isOpen ? 'rotate-180' : ''} text-gray-600 dark:text-neutral-400`}
isOpen ? 'rotate-180' : ''
} text-gray-600 dark:text-neutral-400`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -101,7 +112,7 @@ export default function SidebarFooter() {
</svg> </svg>
</button> </button>
{/* Menü */} {/* Dropdown */}
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<motion.div <motion.div
@ -113,94 +124,70 @@ export default function SidebarFooter() {
> >
<div className="p-2 flex flex-col gap-1"> <div className="p-2 flex flex-col gap-1">
<Button <Button
onClick={() => router.push('/matches')} onClick={() => router.push(`/profile/${session?.user?.steamId}`)}
size='sm' size="sm"
variant='link' variant="link"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors className={linkClass(pathname === `/profile/${session?.user?.steamId}`)}
${pathname === '/matches'
? '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'
}`}
> >
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor"> <svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none"
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25V15.75C9 14.2312 10.2312 13 11.75 13H12.25C13.7688 13 15 14.2312 15 15.75V17.25M4.5 12.75C4.5 11.2312 5.73122 10 7.25 10H7.75C9.26878 10 10.5 11.2312 10.5 12.75V14.25M13.5 10C13.5 8.48122 14.7312 7.25 16.25 7.25H16.75C18.2688 7.25 19.5 8.48122 19.5 10V11.5M4.5 4.5H19.5C20.3284 4.5 21 5.17157 21 6V18C21 18.8284 20.3284 19.5 19.5 19.5H4.5C3.67157 19.5 3 18.8284 3 18V6C3 5.17157 3.67157 4.5 4.5 4.5Z" /> viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
</svg>
Matches
</Button>
<Button
onClick={() => router.push(`/profile/${user.steamId}`)}
size='sm'
variant='link'
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${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'
}`}
>
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" >
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/> <path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg> </svg>
Profil Profil
</Button> </Button>
<Button <Button
onClick={() => router.push(`/team`)} onClick={() => router.push(`/team`)}
size='sm' size="sm"
variant='link' variant="link"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors className={linkClass(pathname === '/team')}
${pathname === `/team`
? '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'
}`}
> >
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > <svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/> <path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg> </svg>
Team Team
</Button> </Button>
<Button <Button
onClick={() => router.push('/settings')} onClick={() => router.push('/settings')}
size='sm' size="sm"
variant='link' variant="link"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors className={linkClass(pathname.startsWith('/settings'))}
${pathname.startsWith('/settings')
? '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'
}`}
> >
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > <svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 19H5a1 1 0 0 1-1-1v-1a3 3 0 0 1 3-3h2m10 1a3 3 0 0 1-3 3m3-3a3 3 0 0 0-3-3m3 3h1m-4 3a3 3 0 0 1-3-3m3 3v1m-3-4a3 3 0 0 1 3-3m-3 3h-1m4-3v-1m-2.121 1.879-.707-.707m5.656 5.656-.707-.707m-4.242 0-.707.707m5.656-5.656-.707.707M12 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/> <path d="M10 19H5a1 1 0 0 1-1-1v-1a3 3 0 0 1 3-3h2m10 1a3 3 0 0 1-3 3m3-3a3 3 0 0 0-3-3m3 3h1m-4 3a3 3 0 0 1-3-3m3 3v1m-3-4a3 3 0 0 1 3-3m-3 3h-1m4-3v-1m-2.121 1.879-.707-.707m5.656 5.656-.707-.707m-4.242 0-.707.707m5.656-5.656-.707.707M12 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg> </svg>
Einstellungen Einstellungen
</Button> </Button>
{user?.isAdmin && (
{session?.user?.isAdmin && (
<Button <Button
onClick={() => router.push('/admin')} onClick={() => router.push('/admin')}
size='sm' size="sm"
variant='link' variant="link"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors className={linkClass(pathname.startsWith('/admin'))}
${pathname.startsWith('/settings/admin')
? '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'
}`}
> >
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > <svg className="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor">
<path transform="scale(0.046875)" d="M78.6 5C69.1-2.4 55.6-1.5 47 7L7 47c-8.5 8.5-9.4 22-2.1 31.6l80 104c4.5 5.9 11.6 9.4 19 9.4l54.1 0 109 109c-14.7 29-10 65.4 14.3 89.6l112 112c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-112-112c-24.2-24.2-60.6-29-89.6-14.3l-109-109 0-54.1c0-7.5-3.5-14.5-9.4-19L78.6 5zM19.9 396.1C7.2 408.8 0 426.1 0 444.1C0 481.6 30.4 512 67.9 512c18 0 35.3-7.2 48-19.9L233.7 374.3c-7.8-20.9-9-43.6-3.6-65.1l-61.7-61.7L19.9 396.1zM512 144c0-10.5-1.1-20.7-3.2-30.5c-2.4-11.2-16.1-14.1-24.2-6l-63.9 63.9c-3 3-7.1 4.7-11.3 4.7L352 176c-8.8 0-16-7.2-16-16l0-57.4c0-4.2 1.7-8.3 4.7-11.3l63.9-63.9c8.1-8.1 5.2-21.8-6-24.2C388.7 1.1 378.5 0 368 0C288.5 0 224 64.5 224 144l0 .8 85.3 85.3c36-9.1 75.8 .5 104 28.7L429 274.5c49-23 83-72.8 83-130.5zM56 432a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z"/> <path transform="scale(0.046875)" d="M78.6 5C69.1-2.4 55.6-1.5 47 7L7 47c-8.5 8.5-9.4 22-2.1 31.6l80 104c4.5 5.9 11.6 9.4 19 9.4l54.1 0 109 109c-14.7 29-10 65.4 14.3 89.6l112 112c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-112-112c-24.2-24.2-60.6-29-89.6-14.3l-109-109 0-54.1c0-7.5-3.5-14.5-9.4-19L78.6 5zM19.9 396.1C7.2 408.8 0 426.1 0 444.1C0 481.6 30.4 512 67.9 512c18 0 35.3-7.2 48-19.9L233.7 374.3c-7.8-20.9-9-43.6-3.6-65.1l-61.7-61.7L19.9 396.1zM512 144c0-10.5-1.1-20.7-3.2-30.5c-2.4-11.2-16.1-14.1-24.2-6l-63.9 63.9c-3 3-7.1 4.7-11.3 4.7L352 176c-8.8 0-16-7.2-16-16l0-57.4c0-4.2 1.7-8.3 4.7-11.3l63.9-63.9c8.1-8.1 5.2-21.8-6-24.2C388.7 1.1 378.5 0 368 0C288.5 0 224 64.5 224 144l0 .8 85.3 85.3c36-9.1 75.8 .5 104 28.7L429 274.5c49-23 83-72.8 83-130.5zM56 432a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z"/>
</svg> </svg>
Administration Administration
</Button> </Button>
)} )}
<Button <Button
onClick={() => signOut({ callbackUrl: '/' })} onClick={() => signOut({ callbackUrl: '/' })}
size='sm' size="sm"
variant='link' variant="link"
color='red' color="red"
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors text-gray-800 hover:bg-red-100 dark:text-neutral-300 dark:hover:bg-red-700`} > className="flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors text-gray-800 hover:bg-red-100 dark:text-neutral-300 dark:hover:bg-red-700"
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > >
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/> <path d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/>
</svg> </svg>
Abmelden Abmelden
</Button> </Button>
</div> </div>
@ -208,6 +195,5 @@ export default function SidebarFooter() {
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
</>
) )
} }

View File

@ -1,6 +1,7 @@
// TeamCardComponent.tsx
'use client' 'use client'
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useEffect, useRef, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import TeamInvitationView from './TeamInvitationView' import TeamInvitationView from './TeamInvitationView'
@ -11,29 +12,12 @@ import CreateTeamButton from './CreateTeamButton'
import type { Player, Team } from '../types/team' import type { Player, Team } from '../types/team'
import type { Invitation } from '../types/invitation' import type { Invitation } from '../types/invitation'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import {
/** Relevante Event-Gruppen */ INVITE_EVENTS,
const TEAM_EVENTS = new Set([ TEAM_EVENTS,
'team-updated', SELF_EVENTS,
'team-leader-changed', isSseEventType,
'team-member-joined', } from '@/app/lib/sseEvents'
'team-member-left',
'team-renamed',
'team-logo-updated',
])
const SELF_CLEAR_EVENTS = new Set([
'team-kick-self',
'team-left-self',
'user-team-cleared',
])
const INVITE_EVENTS = new Set([
'team-invite',
'team-join-request',
'team-invite-reject',
'team-join-request-reject',
])
type Props = { refetchKey?: string } type Props = { refetchKey?: string }
@ -51,11 +35,16 @@ function eqTeam(a: Team | null, b: Team | null) {
if (a.id !== b.id || a.name !== b.name || a.logo !== b.logo || a.leader !== b.leader) { if (a.id !== b.id || a.name !== b.name || a.logo !== b.logo || a.leader !== b.leader) {
return false return false
} }
// Spielerlisten flach vergleichen (nach steamId sortiert vergleichen)
const sort = (arr: Player[] = []) => [...arr].sort((x, y) => x.steamId.localeCompare(y.steamId)) const sort = (arr: Player[] = []) => [...arr].sort((x, y) => x.steamId.localeCompare(y.steamId))
return eqPlayers(sort(a.activePlayers), sort(b.activePlayers)) && return eqPlayers(sort(a.activePlayers), sort(b.activePlayers)) &&
eqPlayers(sort(a.inactivePlayers), sort(b.inactivePlayers)) eqPlayers(sort(a.inactivePlayers), sort(b.inactivePlayers))
} }
function eqInvites(a: Invitation[] = [], b: Invitation[] = []) {
if (a.length !== b.length) return false
const A = a.map(x => x.id).sort().join(',')
const B = b.map(x => x.id).sort().join(',')
return A === B
}
function TeamCardComponent(_: Props, _ref: any) { function TeamCardComponent(_: Props, _ref: any) {
const { data: session } = useSession() const { data: session } = useSession()
@ -65,7 +54,9 @@ function TeamCardComponent(_: Props, _ref: any) {
// State // State
const [initialLoading, setInitialLoading] = useState(true) // nur beim ersten Load true const [initialLoading, setInitialLoading] = useState(true) // nur beim ersten Load true
const [team, setTeam] = useState<Team | null>(null) const [team, setTeam] = useState<Team | null>(null)
const [pendingInvitation, setPendingInvitation] = useState<Invitation | null>(null)
// ⬇️ Mehrere Pending-Invites statt nur eines
const [pendingInvitations, setPendingInvitations] = useState<Invitation[]>([])
const [activeDragItem, setActiveDragItem] = useState<Player | null>(null) const [activeDragItem, setActiveDragItem] = useState<Player | null>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
@ -103,22 +94,21 @@ function TeamCardComponent(_: Props, _ref: any) {
if (!eqTeam(team, data.team)) { if (!eqTeam(team, data.team)) {
setTeam(data.team) setTeam(data.team)
} }
setPendingInvitation(null)
currentTeamIdRef.current = data.team.id currentTeamIdRef.current = data.team.id
if (pendingInvitations.length) setPendingInvitations([]) // im Team → keine Fremd-Invites zeigen
} else { } else {
// Kein Team → optional Invites laden (aber nicht im Millisekundentakt) // Kein Team → optional ALLE Invites laden (aber nicht im Millisekundentakt)
currentTeamIdRef.current = null currentTeamIdRef.current = null
if (Date.now() - lastInviteCheck.current > 1500) { if (Date.now() - lastInviteCheck.current > 1500) {
lastInviteCheck.current = Date.now() lastInviteCheck.current = Date.now()
const inviteRes = await fetch('/api/user/invitations', { cache: 'no-store', signal: ac.signal }) const inviteRes = await fetch('/api/user/invitations', { cache: 'no-store', signal: ac.signal })
if (inviteRes.ok) { if (inviteRes.ok) {
const inviteData = await inviteRes.json() const inviteData = await inviteRes.json()
const raw = (inviteData.invitations ?? []).find((i: any) => i.type === 'team-invite') const all: Invitation[] = (inviteData.invitations ?? [])
const inv: Invitation | null = raw && raw.team ? { id: raw.id, team: raw.team } : null .filter((i: any) => i.type === 'team-invite' && i.team)
// nur setzen, wenn es sich ändert .map((i: any) => ({ id: i.id, team: i.team }))
if ((pendingInvitation?.id ?? null) !== (inv?.id ?? null)) {
setPendingInvitation(inv) setPendingInvitations(prev => (eqInvites(prev, all) ? prev : all))
}
} }
} }
if (team !== null) setTeam(null) // nur setzen, wenn nötig if (team !== null) setTeam(null) // nur setzen, wenn nötig
@ -149,36 +139,58 @@ function TeamCardComponent(_: Props, _ref: any) {
// Auf SSE-Events reagieren → nur soft reload (kein Spinner) // Auf SSE-Events reagieren → nur soft reload (kein Spinner)
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
if (!isSseEventType(lastEvent.type)) return
const { type, payload } = lastEvent const { type, payload } = lastEvent
// selbst entfernt/gekickt → reload // a) selbst entfernt/gekickt/cleared
if (SELF_CLEAR_EVENTS.has(type)) { if (SELF_EVENTS.has(type)) {
fetchData(false) fetchData(false)
return return
} }
// Team-Events: nur laden, wenn es das aktuelle Team betrifft (oder keine teamId angegeben → fail-safe) // b) Einladung zurückgezogen → Karte sofort weg + neu laden
if (type === 'team-invite-revoked') {
const revokedId = payload?.invitationId as string | undefined
const revokedTeamId = payload?.teamId as string | undefined
if (revokedId || revokedTeamId) {
setPendingInvitations(prev =>
prev.filter(i =>
(revokedId ? i.id !== revokedId : true) &&
(revokedTeamId ? i.team.id !== revokedTeamId : true)
)
)
}
fetchData(false)
return
}
// c) Team-Events → immer nachladen
if (TEAM_EVENTS.has(type)) { if (TEAM_EVENTS.has(type)) {
fetchData(false) fetchData(false)
return return
} }
// Invite-Events: nur interessant, wenn kein Team // d) Invite-Events (nur wenn man in keinem Team ist)
if (INVITE_EVENTS.has(type) && !currentTeamIdRef.current) { if (INVITE_EVENTS.has(type) && !currentTeamIdRef.current) {
fetchData(false) fetchData(false)
return return
} }
}, [lastEvent]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEvent, pendingInvitations])
if (initialLoading) return <LoadingSpinner /> if (initialLoading) return <LoadingSpinner />
// 1) Pending Team-Einladung anzeigen (nur wenn kein Team vorhanden) // 1) Pending Team-Einladungen anzeigen (nur wenn kein Team vorhanden)
if (!team && pendingInvitation) { if (!team && pendingInvitations.length > 0) {
return ( return (
<> <>
<div className="space-y-4">
{pendingInvitations.map(inv => (
<TeamInvitationView <TeamInvitationView
invitation={pendingInvitation} key={inv.id}
notificationId={pendingInvitation.id} invitation={inv}
notificationId={inv.id}
onMarkAsRead={async () => {}} onMarkAsRead={async () => {}}
onAction={async (action) => { onAction={async (action) => {
try { try {
@ -186,18 +198,21 @@ function TeamCardComponent(_: Props, _ref: any) {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
invitationId: pendingInvitation.id, invitationId: inv.id,
teamId: pendingInvitation.team.id, teamId: inv.team.id,
}), }),
}) })
} catch (e) { } catch (e) {
console.error('Invite respond fehlgeschlagen:', e) console.error('Invite respond fehlgeschlagen:', e)
} finally { } finally {
// nach Aktion erneut prüfen (soft) // lokal entfernen + soft reload
setPendingInvitations(list => list.filter(x => x.id !== inv.id))
await fetchData(false) await fetchData(false)
} }
}} }}
/> />
))}
</div>
<NoTeamView /> <NoTeamView />
</> </>
) )
@ -206,12 +221,12 @@ function TeamCardComponent(_: Props, _ref: any) {
// 2) Kein Team & keine Einladung // 2) Kein Team & keine Einladung
if (!team) { if (!team) {
return ( return (
<div className="p-6 bg-white dark:bg-neutral-900 border rounded-lg dark:border-neutral-700 space-y-4"> <>
<NoTeamView /> <NoTeamView />
<div className="pt-2"> <div className="pt-2">
<CreateTeamButton setRefetchKey={setRefetchKey} /> <CreateTeamButton setRefetchKey={setRefetchKey} />
</div> </div>
</div> </>
) )
} }

View File

@ -64,7 +64,7 @@ export default function TeamInvitationView({
{/* Inhalt */} {/* Inhalt */}
<div className="relative z-[1] p-4"> <div className="relative z-[1] p-4">
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img <img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`} src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
@ -101,7 +101,7 @@ export default function TeamInvitationView({
<Button <Button
title="Ablehnen" title="Ablehnen"
size="sm" size="sm"
color="gray" color="red"
variant="ghost" variant="ghost"
disabled={isSubmitting !== null} disabled={isSubmitting !== null}
onClick={(e) => { onClick={(e) => {
@ -154,21 +154,21 @@ export default function TeamInvitationView({
/* weicher, dezenter Verlauf */ /* weicher, dezenter Verlauf */
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
90deg, 90deg,
rgba(16,185,129,0.10) 0%, rgba(16, 168, 54, 0.20) 0%,
rgba(16,185,129,0.06) 50%, rgba(16, 168, 54, 0.04) 50%,
rgba(16,185,129,0.10) 100% rgba(16, 168, 54, 0.20) 100%
); );
background-size: 200% 100%; background-size: 200% 100%;
background-repeat: repeat-x; /* nahtlos, kein “Sprung” am Loop */ background-repeat: repeat-x; /* nahtlos, kein “Sprung” am Loop */
animation: slide-x 20s linear infinite; /* langsam, konstant, endlos */ animation: slide-x 3s linear infinite; /* langsam, konstant, endlos */
} }
:global(.dark) .invitationGradient { :global(.dark) .invitationGradient {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
90deg, 90deg,
rgba(16,185,129,0.18) 0%, rgba(16, 168, 54, 0.28) 0%,
rgba(16,185,129,0.08) 50%, rgba(16, 168, 54, 0.08) 50%,
rgba(16,185,129,0.18) 100% rgba(16, 168, 54, 0.28) 100%
); );
} }

View File

@ -20,6 +20,12 @@ import Link from 'next/link'
import { Team } from '../types/team' import { Team } from '../types/team'
import { useTeamStore } from '../lib/stores' import { useTeamStore } from '../lib/stores'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import {
TEAM_EVENTS,
SELF_EVENTS,
isSseEventType,
type SSEEventType,
} from '@/app/lib/sseEvents'
type Props = { type Props = {
team?: Team team?: Team
@ -55,6 +61,11 @@ export default function TeamMemberView({
const team = teamProp ?? storeTeam const team = teamProp ?? storeTeam
if (!team) return null if (!team) return null
const RELEVANT: ReadonlySet<SSEEventType> = new Set([
...TEAM_EVENTS,
...SELF_EVENTS,
])
const isLeader = currentUserSteamId === team.leader const isLeader = currentUserSteamId === team.leader
const canManage = adminMode || isLeader const canManage = adminMode || isLeader
const canInvite = isLeader && !adminMode const canInvite = isLeader && !adminMode
@ -122,29 +133,18 @@ export default function TeamMemberView({
useEffect(() => { useEffect(() => {
if (!lastEvent || !team?.id) return if (!lastEvent || !team?.id) return
const RELEVANT = new Set([ // Typ-Safety: nur kanonische Events weiter verarbeiten
'team-updated', if (!isSseEventType(lastEvent.type)) return
'team-leader-changed', const evtType: SSEEventType = lastEvent.type
'team-member-joined',
'team-member-left',
'team-kick',
'team-kick-other',
'team-left',
'team-renamed',
'team-logo-updated',
])
if (!RELEVANT.has(lastEvent.type)) return if (!RELEVANT.has(evtType)) return
const payload = lastEvent.payload ?? {} const payload = lastEvent.payload ?? {}
// meistens ist teamId dabei falls einzelne Events andere Felder nutzen,
// hier entsprechend ergänzen (z. B. payload.oldTeamId/payload.newTeamId).
if (payload.teamId !== team.id) return if (payload.teamId !== team.id) return
;(async () => { ;(async () => {
const updated = await reloadTeam(team.id) const updated = await reloadTeam(team.id)
if (!updated) return if (!updated) return
setTeam(updated) setTeam(updated)
setEditedName(updated.name || '') setEditedName(updated.name || '')
@ -216,16 +216,44 @@ export default function TeamMemberView({
isDraggingRef.current = false isDraggingRef.current = false
const { active, over } = event const { active, over } = event
if (!over) return if (!over) {
// falls während des Drags Remote-Updates ankamen → jetzt anwenden
if (pendingRemote) {
setActivePlayers(pendingRemote.active)
setInactivePlayers(pendingRemote.inactive)
setInvitedPlayers(pendingRemote.invited)
setPendingRemote(null)
}
return
}
const activeId = String(active.id) const activeId = String(active.id)
const overId = String(over.id) const overId = String(over.id)
const movingItem = [...activePlayers, ...inactivePlayers].find(p => p.steamId === activeId) const movingItem =
activePlayers.find(p => p.steamId === activeId) ||
inactivePlayers.find(p => p.steamId === activeId)
if (!movingItem) return if (!movingItem) return
// Woher kam die Karte?
const wasInActive = activePlayers.some(p => p.steamId === activeId)
// Wohin wurde gedroppt?
const dropToActive = const dropToActive =
overId === 'active' || activePlayers.some(p => p.steamId === overId) overId === 'active' || activePlayers.some(p => p.steamId === overId)
// 🚫 Selbe Zone -> nichts speichern, nur evtl. pendingRemote anwenden
if ((wasInActive && dropToActive) || (!wasInActive && !dropToActive)) {
if (pendingRemote) {
setActivePlayers(pendingRemote.active)
setInactivePlayers(pendingRemote.inactive)
setInvitedPlayers(pendingRemote.invited)
setPendingRemote(null)
}
return
}
// ── tatsächliche Zonen-Änderung ──────────────────────────────────
let nextActive = [...activePlayers] let nextActive = [...activePlayers]
let nextInactive = [...inactivePlayers] let nextInactive = [...inactivePlayers]
@ -238,27 +266,39 @@ export default function TeamMemberView({
if (!nextInactive.some(p => p.steamId === activeId)) nextInactive.push(movingItem) if (!nextInactive.some(p => p.steamId === activeId)) nextInactive.push(movingItem)
} }
// deine UI sortiert eh alphabetisch → Reihenfolge innerhalb der Zone ist egal
nextActive.sort((a,b)=>a.name.localeCompare(b.name)) nextActive.sort((a,b)=>a.name.localeCompare(b.name))
nextInactive.sort((a,b)=>a.name.localeCompare(b.name)) nextInactive.sort((a,b)=>a.name.localeCompare(b.name))
// ✅ Optimistisches UI: nur lokale DnD-States updaten // 🔍 Sicherheit: Wenn sich durch die Operation nichts geändert hat → abbrechen
const noChange =
eqByIds(nextActive, activePlayers) && eqByIds(nextInactive, inactivePlayers)
if (noChange) {
if (pendingRemote) {
setActivePlayers(pendingRemote.active)
setInactivePlayers(pendingRemote.inactive)
setInvitedPlayers(pendingRemote.invited)
setPendingRemote(null)
}
return
}
// ✅ Optimistisches UI
setActivePlayers(nextActive) setActivePlayers(nextActive)
setInactivePlayers(nextInactive) setInactivePlayers(nextInactive)
// 🔔 Server informieren (SSE triggert andere Clients) // 🔔 Server informieren
updateTeamMembers(team.id, nextActive, nextInactive).catch(console.error) updateTeamMembers(team.id, nextActive, nextInactive).catch(console.error)
setSaveSuccess(true) setSaveSuccess(true)
setTimeout(()=>setSaveSuccess(false), 3000) setTimeout(()=>setSaveSuccess(false), 3000)
// 📨 Falls während des Drags ein Remote-Update kam → jetzt anwenden // 📨 evtl. gepufferte Remote-Änderungen übernehmen
if (pendingRemote) { if (pendingRemote) {
// nur übernehmen, wenn abweichend (optional)
const diff = const diff =
!eqByIds(pendingRemote.active, nextActive) || !eqByIds(pendingRemote.active, nextActive) ||
!eqByIds(pendingRemote.inactive, nextInactive) || !eqByIds(pendingRemote.inactive, nextInactive) ||
!eqByIds(pendingRemote.invited, invitedPlayers) !eqByIds(pendingRemote.invited, invitedPlayers)
if (diff) { if (diff) {
setActivePlayers(pendingRemote.active) setActivePlayers(pendingRemote.active)
setInactivePlayers(pendingRemote.inactive) setInactivePlayers(pendingRemote.inactive)
@ -460,16 +500,15 @@ export default function TeamMemberView({
<div className="flex gap-2"> <div className="flex gap-2">
{canManage && ( {canManage && (
<button <Button
type="button"
onClick={() => setShowDeleteModal(true)} onClick={() => setShowDeleteModal(true)}
className="text-sm px-3 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700" color='red'
size='sm'
> >
Team löschen Team löschen
</button> </Button>
)} )}
<button <Button
type="button"
onClick={async () => { onClick={async () => {
if (isLeader) { if (isLeader) {
setShowLeaveModal(true) setShowLeaveModal(true)
@ -477,10 +516,11 @@ export default function TeamMemberView({
try { await leaveTeam(currentUserSteamId) } catch (err) { console.error('Fehler beim Verlassen:', err) } try { await leaveTeam(currentUserSteamId) } catch (err) { console.error('Fehler beim Verlassen:', err) }
} }
}} }}
className="text-sm px-3 py-1.5 bg-gray-200 text-black rounded-lg hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600" color='blue'
size='sm'
> >
Team verlassen Team verlassen
</button> </Button>
</div> </div>
</div> </div>
@ -504,7 +544,7 @@ export default function TeamMemberView({
setTimeout(() => setShowInviteModal(true), 0) setTimeout(() => setShowInviteModal(true), 0)
}} }}
> >
<div className="flex items-center justify-center w-16 h-16 bg-white rounded-full"> <div className="flex items-center justify-center w-16 h-16 bg-white rounded-full text-black">
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" viewBox="0 0 640 512" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" viewBox="0 0 640 512" fill="currentColor">
<path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM504 312v-64h-64c-13.3 0-24-10.7-24-24s10.7-24 24-24h64v-64c0-13.3 10.7-24 24-24s24 10.7 24 24v64h64c13.3 0 24 10.7 24 24s-10.7 24-24 24h-64v64c0 13.3-10.7 24-24 24s-24-10.7-24-24z" /> <path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM504 312v-64h-64c-13.3 0-24-10.7-24-24s10.7-24 24-24h64v-64c0-13.3 10.7-24 24-24s24 10.7 24 24v64h64c13.3 0 24 10.7 24 24s-10.7 24-24 24h-64v64c0 13.3-10.7 24-24 24s-24-10.7-24-24z" />
</svg> </svg>
@ -538,8 +578,31 @@ export default function TeamMemberView({
isSelectable={false} isSelectable={false}
isInvite={true} isInvite={true}
rank={player.premierRank} rank={player.premierRank}
onKick={revokeInvitation}
invitationId={(player as any).invitationId} invitationId={(player as any).invitationId}
onKick={async (sid) => {
// 1) optimistisch entfernen
setInvitedPlayers(list => list.filter(p => p.steamId !== sid))
try {
// 2) API: mit invitationId ODER Fallback teamId+steamId
await fetch('/api/user/invitations/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invitationId: (player as any).invitationId ?? undefined,
teamId: team.id,
steamId: sid,
}),
})
} catch (e) {
console.error('Revoke fehlgeschlagen:', e)
// optional: bei Fehler wieder einfügen
setInvitedPlayers(list => [...list, player].sort((a,b)=>a.name.localeCompare(b.name)))
} finally {
// 3) sicherheitshalber Team neu laden
const updated = await reloadTeam(team.id)
if (updated) setTeam(updated)
}
}}
/> />
</motion.div> </motion.div>
))} ))}
@ -669,7 +732,7 @@ export default function TeamMemberView({
body: JSON.stringify({ teamId: team.id }), body: JSON.stringify({ teamId: team.id }),
}) })
setShowDeleteModal(false) setShowDeleteModal(false)
window.location.href = '/' window.location.href = '/team'
}} }}
closeButtonTitle="Löschen" closeButtonTitle="Löschen"
closeButtonColor="red" closeButtonColor="red"

View File

@ -57,8 +57,6 @@ export default function MatchesAdminManager() {
} }
return ( return (
<Card maxWidth='auto'>
<CommunityMatchList matchType="community" /> <CommunityMatchList matchType="community" />
</Card>
) )
} }

View File

@ -24,6 +24,12 @@ interface Match {
rankNew : number rankNew : number
rankOld : number rankOld : number
rankChange : number | null rankChange : number | null
oneK : number
twoK : number
threeK : number
fourK : number
fiveK : number
aim : number
} }
/* ───────── Hilfsfunktionen ───────── */ /* ───────── Hilfsfunktionen ───────── */
@ -51,7 +57,7 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
{/* Kopf */} {/* Kopf */}
<Table.Head> <Table.Head>
<Table.Row> <Table.Row>
{['Map','Date','Score','Rank','Kills','Deaths','K/D'].map(h => ( {['Map','Date','Score','Rank','Aim','Kills','Deaths','K/D'].map(h => (
<Table.Cell key={h} as="th">{h}</Table.Cell> <Table.Cell key={h} as="th">{h}</Table.Cell>
))} ))}
</Table.Row> </Table.Row>
@ -125,6 +131,11 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
</Table.Cell> </Table.Cell>
{/* Stats */} {/* Stats */}
<Table.Cell>
{Number.isFinite(Number(m.aim))
? `${Number(m.aim).toFixed(0)} %`
: '-'}
</Table.Cell>
<Table.Cell>{m.kills}</Table.Cell> <Table.Cell>{m.kills}</Table.Cell>
<Table.Cell>{m.deaths}</Table.Cell> <Table.Cell>{m.deaths}</Table.Cell>
<Table.Cell>{m.kdr}</Table.Cell> <Table.Cell>{m.kdr}</Table.Cell>

View File

@ -1,15 +0,0 @@
// hooks/useSteamProfile.ts
import { useSession } from 'next-auth/react'
import { SteamProfile } from '../types/steam'
export const useSteamProfile = () => {
const { data: session, status } = useSession()
const steamProfile = session?.user as SteamProfile | undefined
return {
session,
steamProfile,
status, // kann 'loading' | 'authenticated' | 'unauthenticated' sein
}
}

View File

@ -4,7 +4,7 @@ export const SSE_EVENT_TYPES = [
// Kanonisch // Kanonisch
'team-updated', 'team-updated',
'team-leader-changed', 'team-leader-changed',
'team-leader-self', // ⬅️ neu 'team-leader-self',
'team-renamed', 'team-renamed',
'team-logo-updated', 'team-logo-updated',
'team-member-joined', 'team-member-joined',
@ -17,34 +17,20 @@ export const SSE_EVENT_TYPES = [
'invitation', 'invitation',
'team-invite', 'team-invite',
'team-join-request', 'team-join-request',
'team-joined', // ⬅️ neu (eigene Bestätigung) 'team-joined',
'expired-sharecode', 'expired-sharecode',
'team-invite-revoked',
// optional/robust, nur falls noch emittiert:
// 'team-invite-reject',
// 'team-join-request-reject',
] as const; ] as const;
export type SSEEventType = typeof SSE_EVENT_TYPES[number]; export type SSEEventType = typeof SSE_EVENT_TYPES[number];
/** Legacy-Namen, die noch von alten Endpunkten kommen können */ /** Type Guard */
export const SSE_LEGACY_EVENT_TYPES = [
'team-kick',
'team-kick-other',
'team-left',
] as const;
export type SSELegacyEventType = typeof SSE_LEGACY_EVENT_TYPES[number];
/** Type Guards */
export function isSseEventType(x: unknown): x is SSEEventType { export function isSseEventType(x: unknown): x is SSEEventType {
return typeof x === 'string' && (SSE_EVENT_TYPES as readonly string[]).includes(x as any); return typeof x === 'string' && (SSE_EVENT_TYPES as readonly string[]).includes(x as any);
} }
export function isLegacyType(x: unknown): x is SSELegacyEventType {
return typeof x === 'string' && (SSE_LEGACY_EVENT_TYPES as readonly string[]).includes(x as any);
}
/** Sinnvolle Gruppen */ /** Sinnvolle Gruppen */
export const TEAM_EVENTS = new Set<SSEEventType>([ export const TEAM_EVENTS: ReadonlySet<SSEEventType> = new Set([
'team-updated', 'team-updated',
'team-leader-changed', 'team-leader-changed',
'team-renamed', 'team-renamed',
@ -54,52 +40,32 @@ export const TEAM_EVENTS = new Set<SSEEventType>([
'team-member-kicked', 'team-member-kicked',
]); ]);
export const SELF_EVENTS = new Set<SSEEventType>([ export const INVITE_EVENTS: ReadonlySet<SSEEventType> = new Set([
'team-invite',
'team-join-request',
'team-invite-revoked',
]);
export const SELF_EVENTS: ReadonlySet<SSEEventType> = new Set([
'team-kick-self', 'team-kick-self',
'team-left-self', 'team-left-self',
'user-team-cleared', 'user-team-cleared',
'team-leader-self', // ⬅️ neu (nur für den neuen Leader relevant) 'team-leader-self',
]); ]);
// Nur die Event-Typen, die das NotificationCenter betreffen (Preview/Live-Dropdown) // Event-Typen, die das NotificationCenter betreffen
export const NOTIFICATION_EVENTS = new Set([ export const NOTIFICATION_EVENTS: ReadonlySet<SSEEventType> = new Set([
'notification', 'notification',
'invitation', 'invitation',
'team-invite', 'team-invite',
'team-join-request', 'team-join-request',
'expired-sharecode',
// ⬇️ damit „… ist deinem Team beigetreten“ & Leader-Änderungen live erscheinen
'team-member-joined',
'team-joined', 'team-joined',
'team-leader-changed', 'team-leader-changed',
'team-leader-self', 'team-leader-self',
'expired-sharecode',
// optional/robust:
// 'team-invite-reject',
// 'team-join-request-reject',
]); ]);
/** Legacy → Kanonisch normalisieren */ /** Nur noch: akzeptiere kanonische Typen, sonst null */
export function normalizeEventType( export function normalizeEventType(incoming: string): SSEEventType | null {
incoming: string, return isSseEventType(incoming) ? incoming : null;
payload: any,
selfSteamId?: string | null,
): SSEEventType | null {
if (isSseEventType(incoming)) return incoming;
if (!isLegacyType(incoming)) return null;
switch (incoming) {
case 'team-kick':
return 'team-kick-self';
case 'team-kick-other':
return 'team-member-kicked';
case 'team-left':
if (payload?.userId && selfSteamId && payload.userId === selfSteamId) {
return 'team-left-self';
}
return 'team-member-left';
default:
return null;
}
} }

View File

@ -1,6 +1,6 @@
// src/app/types/match.ts // src/app/types/match.ts
import { Player } from './team' import { Player, TeamMatches } from './team'
export type Match = { export type Match = {
/* Basis-Infos ---------------------------------------------------- */ /* Basis-Infos ---------------------------------------------------- */
@ -19,20 +19,8 @@ export type Match = {
winnerTeam? : 'CT' | 'T' | 'Draw' | null winnerTeam? : 'CT' | 'T' | 'Draw' | null
/* Teams ---------------------------------------------------------- */ /* Teams ---------------------------------------------------------- */
teamA: { teamA: TeamMatches
id : string teamB: TeamMatches
name : string
logo? : string | null
leader?: string | null
players: MatchPlayer[]
}
teamB: {
id : string
name : string
logo? : string | null
leader?: string | null
players: MatchPlayer[]
}
} }
/* --------------------------------------------------------------- */ /* --------------------------------------------------------------- */
@ -57,11 +45,12 @@ export type MatchPlayer = {
rankOld : number rankOld : number
rankNew : number rankNew : number
rankChange : number rankChange : number
k1 : number aim : number
k2 : number oneK : number
k3 : number twoK : number
k4 : number threeK : number
k5 : number fourK : number
fiveK : number
} }
} }

View File

@ -1,3 +1,5 @@
import { MatchPlayer } from "@/generated/prisma"
// /types/team.ts // /types/team.ts
export type Player = { export type Player = {
steamId: string steamId: string
@ -12,7 +14,6 @@ export type InvitedPlayer = Player & {
invitationId: string invitationId: string
} }
export type Team = { export type Team = {
id: string id: string
name?: string | null name?: string | null
@ -22,3 +23,11 @@ export type Team = {
inactivePlayers: Player[] inactivePlayers: Player[]
invitedPlayers: InvitedPlayer[] invitedPlayers: InvitedPlayer[]
} }
export type TeamMatches = {
id: string
name?: string | null
logo?: string | null
leader?: string | null
players: MatchPlayer[]
}

File diff suppressed because one or more lines are too long

View File

@ -214,11 +214,12 @@ exports.Prisma.PlayerStatsScalarFieldEnum = {
headshots: 'headshots', headshots: 'headshots',
noScopes: 'noScopes', noScopes: 'noScopes',
blindKills: 'blindKills', blindKills: 'blindKills',
k1: 'k1', aim: 'aim',
k2: 'k2', oneK: 'oneK',
k3: 'k3', twoK: 'twoK',
k4: 'k4', threeK: 'threeK',
k5: 'k5', fourK: 'fourK',
fiveK: 'fiveK',
rankOld: 'rankOld', rankOld: 'rankOld',
rankNew: 'rankNew', rankNew: 'rankNew',
rankChange: 'rankChange', rankChange: 'rankChange',

View File

@ -9630,11 +9630,12 @@ export namespace Prisma {
headshots: number | null headshots: number | null
noScopes: number | null noScopes: number | null
blindKills: number | null blindKills: number | null
k1: number | null aim: number | null
k2: number | null oneK: number | null
k3: number | null twoK: number | null
k4: number | null threeK: number | null
k5: number | null fourK: number | null
fiveK: number | null
rankOld: number | null rankOld: number | null
rankNew: number | null rankNew: number | null
rankChange: number | null rankChange: number | null
@ -9660,11 +9661,12 @@ export namespace Prisma {
headshots: number | null headshots: number | null
noScopes: number | null noScopes: number | null
blindKills: number | null blindKills: number | null
k1: number | null aim: number | null
k2: number | null oneK: number | null
k3: number | null twoK: number | null
k4: number | null threeK: number | null
k5: number | null fourK: number | null
fiveK: number | null
rankOld: number | null rankOld: number | null
rankNew: number | null rankNew: number | null
rankChange: number | null rankChange: number | null
@ -9693,11 +9695,12 @@ export namespace Prisma {
headshots: number | null headshots: number | null
noScopes: number | null noScopes: number | null
blindKills: number | null blindKills: number | null
k1: number | null aim: number | null
k2: number | null oneK: number | null
k3: number | null twoK: number | null
k4: number | null threeK: number | null
k5: number | null fourK: number | null
fiveK: number | null
rankOld: number | null rankOld: number | null
rankNew: number | null rankNew: number | null
rankChange: number | null rankChange: number | null
@ -9726,11 +9729,12 @@ export namespace Prisma {
headshots: number | null headshots: number | null
noScopes: number | null noScopes: number | null
blindKills: number | null blindKills: number | null
k1: number | null aim: number | null
k2: number | null oneK: number | null
k3: number | null twoK: number | null
k4: number | null threeK: number | null
k5: number | null fourK: number | null
fiveK: number | null
rankOld: number | null rankOld: number | null
rankNew: number | null rankNew: number | null
rankChange: number | null rankChange: number | null
@ -9759,11 +9763,12 @@ export namespace Prisma {
headshots: number headshots: number
noScopes: number noScopes: number
blindKills: number blindKills: number
k1: number aim: number
k2: number oneK: number
k3: number twoK: number
k4: number threeK: number
k5: number fourK: number
fiveK: number
rankOld: number rankOld: number
rankNew: number rankNew: number
rankChange: number rankChange: number
@ -9791,11 +9796,12 @@ export namespace Prisma {
headshots?: true headshots?: true
noScopes?: true noScopes?: true
blindKills?: true blindKills?: true
k1?: true aim?: true
k2?: true oneK?: true
k3?: true twoK?: true
k4?: true threeK?: true
k5?: true fourK?: true
fiveK?: true
rankOld?: true rankOld?: true
rankNew?: true rankNew?: true
rankChange?: true rankChange?: true
@ -9821,11 +9827,12 @@ export namespace Prisma {
headshots?: true headshots?: true
noScopes?: true noScopes?: true
blindKills?: true blindKills?: true
k1?: true aim?: true
k2?: true oneK?: true
k3?: true twoK?: true
k4?: true threeK?: true
k5?: true fourK?: true
fiveK?: true
rankOld?: true rankOld?: true
rankNew?: true rankNew?: true
rankChange?: true rankChange?: true
@ -9854,11 +9861,12 @@ export namespace Prisma {
headshots?: true headshots?: true
noScopes?: true noScopes?: true
blindKills?: true blindKills?: true
k1?: true aim?: true
k2?: true oneK?: true
k3?: true twoK?: true
k4?: true threeK?: true
k5?: true fourK?: true
fiveK?: true
rankOld?: true rankOld?: true
rankNew?: true rankNew?: true
rankChange?: true rankChange?: true
@ -9887,11 +9895,12 @@ export namespace Prisma {
headshots?: true headshots?: true
noScopes?: true noScopes?: true
blindKills?: true blindKills?: true
k1?: true aim?: true
k2?: true oneK?: true
k3?: true twoK?: true
k4?: true threeK?: true
k5?: true fourK?: true
fiveK?: true
rankOld?: true rankOld?: true
rankNew?: true rankNew?: true
rankChange?: true rankChange?: true
@ -9920,11 +9929,12 @@ export namespace Prisma {
headshots?: true headshots?: true
noScopes?: true noScopes?: true
blindKills?: true blindKills?: true
k1?: true aim?: true
k2?: true oneK?: true
k3?: true twoK?: true
k4?: true threeK?: true
k5?: true fourK?: true
fiveK?: true
rankOld?: true rankOld?: true
rankNew?: true rankNew?: true
rankChange?: true rankChange?: true
@ -10040,11 +10050,12 @@ export namespace Prisma {
headshots: number headshots: number
noScopes: number noScopes: number
blindKills: number blindKills: number
k1: number aim: number
k2: number oneK: number
k3: number twoK: number
k4: number threeK: number
k5: number fourK: number
fiveK: number
rankOld: number | null rankOld: number | null
rankNew: number | null rankNew: number | null
rankChange: number | null rankChange: number | null
@ -10092,11 +10103,12 @@ export namespace Prisma {
headshots?: boolean headshots?: boolean
noScopes?: boolean noScopes?: boolean
blindKills?: boolean blindKills?: boolean
k1?: boolean aim?: boolean
k2?: boolean oneK?: boolean
k3?: boolean twoK?: boolean
k4?: boolean threeK?: boolean
k5?: boolean fourK?: boolean
fiveK?: boolean
rankOld?: boolean rankOld?: boolean
rankNew?: boolean rankNew?: boolean
rankChange?: boolean rankChange?: boolean
@ -10126,11 +10138,12 @@ export namespace Prisma {
headshots?: boolean headshots?: boolean
noScopes?: boolean noScopes?: boolean
blindKills?: boolean blindKills?: boolean
k1?: boolean aim?: boolean
k2?: boolean oneK?: boolean
k3?: boolean twoK?: boolean
k4?: boolean threeK?: boolean
k5?: boolean fourK?: boolean
fiveK?: boolean
rankOld?: boolean rankOld?: boolean
rankNew?: boolean rankNew?: boolean
rankChange?: boolean rankChange?: boolean
@ -10160,11 +10173,12 @@ export namespace Prisma {
headshots?: boolean headshots?: boolean
noScopes?: boolean noScopes?: boolean
blindKills?: boolean blindKills?: boolean
k1?: boolean aim?: boolean
k2?: boolean oneK?: boolean
k3?: boolean twoK?: boolean
k4?: boolean threeK?: boolean
k5?: boolean fourK?: boolean
fiveK?: boolean
rankOld?: boolean rankOld?: boolean
rankNew?: boolean rankNew?: boolean
rankChange?: boolean rankChange?: boolean
@ -10194,18 +10208,19 @@ export namespace Prisma {
headshots?: boolean headshots?: boolean
noScopes?: boolean noScopes?: boolean
blindKills?: boolean blindKills?: boolean
k1?: boolean aim?: boolean
k2?: boolean oneK?: boolean
k3?: boolean twoK?: boolean
k4?: boolean threeK?: boolean
k5?: boolean fourK?: boolean
fiveK?: boolean
rankOld?: boolean rankOld?: boolean
rankNew?: boolean rankNew?: boolean
rankChange?: boolean rankChange?: boolean
winCount?: boolean winCount?: boolean
} }
export type PlayerStatsOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "matchId" | "steamId" | "kills" | "assists" | "deaths" | "headshotPct" | "totalDamage" | "utilityDamage" | "flashAssists" | "mvps" | "mvpEliminations" | "mvpDefuse" | "mvpPlant" | "knifeKills" | "zeusKills" | "wallbangKills" | "smokeKills" | "headshots" | "noScopes" | "blindKills" | "k1" | "k2" | "k3" | "k4" | "k5" | "rankOld" | "rankNew" | "rankChange" | "winCount", ExtArgs["result"]["playerStats"]> export type PlayerStatsOmit<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = $Extensions.GetOmit<"id" | "matchId" | "steamId" | "kills" | "assists" | "deaths" | "headshotPct" | "totalDamage" | "utilityDamage" | "flashAssists" | "mvps" | "mvpEliminations" | "mvpDefuse" | "mvpPlant" | "knifeKills" | "zeusKills" | "wallbangKills" | "smokeKills" | "headshots" | "noScopes" | "blindKills" | "aim" | "oneK" | "twoK" | "threeK" | "fourK" | "fiveK" | "rankOld" | "rankNew" | "rankChange" | "winCount", ExtArgs["result"]["playerStats"]>
export type PlayerStatsInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = { export type PlayerStatsInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
matchPlayer?: boolean | MatchPlayerDefaultArgs<ExtArgs> matchPlayer?: boolean | MatchPlayerDefaultArgs<ExtArgs>
} }
@ -10243,11 +10258,12 @@ export namespace Prisma {
headshots: number headshots: number
noScopes: number noScopes: number
blindKills: number blindKills: number
k1: number aim: number
k2: number oneK: number
k3: number twoK: number
k4: number threeK: number
k5: number fourK: number
fiveK: number
rankOld: number | null rankOld: number | null
rankNew: number | null rankNew: number | null
rankChange: number | null rankChange: number | null
@ -10697,11 +10713,12 @@ export namespace Prisma {
readonly headshots: FieldRef<"PlayerStats", 'Int'> readonly headshots: FieldRef<"PlayerStats", 'Int'>
readonly noScopes: FieldRef<"PlayerStats", 'Int'> readonly noScopes: FieldRef<"PlayerStats", 'Int'>
readonly blindKills: FieldRef<"PlayerStats", 'Int'> readonly blindKills: FieldRef<"PlayerStats", 'Int'>
readonly k1: FieldRef<"PlayerStats", 'Int'> readonly aim: FieldRef<"PlayerStats", 'Int'>
readonly k2: FieldRef<"PlayerStats", 'Int'> readonly oneK: FieldRef<"PlayerStats", 'Int'>
readonly k3: FieldRef<"PlayerStats", 'Int'> readonly twoK: FieldRef<"PlayerStats", 'Int'>
readonly k4: FieldRef<"PlayerStats", 'Int'> readonly threeK: FieldRef<"PlayerStats", 'Int'>
readonly k5: FieldRef<"PlayerStats", 'Int'> readonly fourK: FieldRef<"PlayerStats", 'Int'>
readonly fiveK: FieldRef<"PlayerStats", 'Int'>
readonly rankOld: FieldRef<"PlayerStats", 'Int'> readonly rankOld: FieldRef<"PlayerStats", 'Int'>
readonly rankNew: FieldRef<"PlayerStats", 'Int'> readonly rankNew: FieldRef<"PlayerStats", 'Int'>
readonly rankChange: FieldRef<"PlayerStats", 'Int'> readonly rankChange: FieldRef<"PlayerStats", 'Int'>
@ -15913,11 +15930,12 @@ export namespace Prisma {
headshots: 'headshots', headshots: 'headshots',
noScopes: 'noScopes', noScopes: 'noScopes',
blindKills: 'blindKills', blindKills: 'blindKills',
k1: 'k1', aim: 'aim',
k2: 'k2', oneK: 'oneK',
k3: 'k3', twoK: 'twoK',
k4: 'k4', threeK: 'threeK',
k5: 'k5', fourK: 'fourK',
fiveK: 'fiveK',
rankOld: 'rankOld', rankOld: 'rankOld',
rankNew: 'rankNew', rankNew: 'rankNew',
rankChange: 'rankChange', rankChange: 'rankChange',
@ -16708,11 +16726,12 @@ export namespace Prisma {
headshots?: IntFilter<"PlayerStats"> | number headshots?: IntFilter<"PlayerStats"> | number
noScopes?: IntFilter<"PlayerStats"> | number noScopes?: IntFilter<"PlayerStats"> | number
blindKills?: IntFilter<"PlayerStats"> | number blindKills?: IntFilter<"PlayerStats"> | number
k1?: IntFilter<"PlayerStats"> | number aim?: IntFilter<"PlayerStats"> | number
k2?: IntFilter<"PlayerStats"> | number oneK?: IntFilter<"PlayerStats"> | number
k3?: IntFilter<"PlayerStats"> | number twoK?: IntFilter<"PlayerStats"> | number
k4?: IntFilter<"PlayerStats"> | number threeK?: IntFilter<"PlayerStats"> | number
k5?: IntFilter<"PlayerStats"> | number fourK?: IntFilter<"PlayerStats"> | number
fiveK?: IntFilter<"PlayerStats"> | number
rankOld?: IntNullableFilter<"PlayerStats"> | number | null rankOld?: IntNullableFilter<"PlayerStats"> | number | null
rankNew?: IntNullableFilter<"PlayerStats"> | number | null rankNew?: IntNullableFilter<"PlayerStats"> | number | null
rankChange?: IntNullableFilter<"PlayerStats"> | number | null rankChange?: IntNullableFilter<"PlayerStats"> | number | null
@ -16742,11 +16761,12 @@ export namespace Prisma {
headshots?: SortOrder headshots?: SortOrder
noScopes?: SortOrder noScopes?: SortOrder
blindKills?: SortOrder blindKills?: SortOrder
k1?: SortOrder aim?: SortOrder
k2?: SortOrder oneK?: SortOrder
k3?: SortOrder twoK?: SortOrder
k4?: SortOrder threeK?: SortOrder
k5?: SortOrder fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrderInput | SortOrder rankOld?: SortOrderInput | SortOrder
rankNew?: SortOrderInput | SortOrder rankNew?: SortOrderInput | SortOrder
rankChange?: SortOrderInput | SortOrder rankChange?: SortOrderInput | SortOrder
@ -16780,11 +16800,12 @@ export namespace Prisma {
headshots?: IntFilter<"PlayerStats"> | number headshots?: IntFilter<"PlayerStats"> | number
noScopes?: IntFilter<"PlayerStats"> | number noScopes?: IntFilter<"PlayerStats"> | number
blindKills?: IntFilter<"PlayerStats"> | number blindKills?: IntFilter<"PlayerStats"> | number
k1?: IntFilter<"PlayerStats"> | number aim?: IntFilter<"PlayerStats"> | number
k2?: IntFilter<"PlayerStats"> | number oneK?: IntFilter<"PlayerStats"> | number
k3?: IntFilter<"PlayerStats"> | number twoK?: IntFilter<"PlayerStats"> | number
k4?: IntFilter<"PlayerStats"> | number threeK?: IntFilter<"PlayerStats"> | number
k5?: IntFilter<"PlayerStats"> | number fourK?: IntFilter<"PlayerStats"> | number
fiveK?: IntFilter<"PlayerStats"> | number
rankOld?: IntNullableFilter<"PlayerStats"> | number | null rankOld?: IntNullableFilter<"PlayerStats"> | number | null
rankNew?: IntNullableFilter<"PlayerStats"> | number | null rankNew?: IntNullableFilter<"PlayerStats"> | number | null
rankChange?: IntNullableFilter<"PlayerStats"> | number | null rankChange?: IntNullableFilter<"PlayerStats"> | number | null
@ -16814,11 +16835,12 @@ export namespace Prisma {
headshots?: SortOrder headshots?: SortOrder
noScopes?: SortOrder noScopes?: SortOrder
blindKills?: SortOrder blindKills?: SortOrder
k1?: SortOrder aim?: SortOrder
k2?: SortOrder oneK?: SortOrder
k3?: SortOrder twoK?: SortOrder
k4?: SortOrder threeK?: SortOrder
k5?: SortOrder fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrderInput | SortOrder rankOld?: SortOrderInput | SortOrder
rankNew?: SortOrderInput | SortOrder rankNew?: SortOrderInput | SortOrder
rankChange?: SortOrderInput | SortOrder rankChange?: SortOrderInput | SortOrder
@ -16855,11 +16877,12 @@ export namespace Prisma {
headshots?: IntWithAggregatesFilter<"PlayerStats"> | number headshots?: IntWithAggregatesFilter<"PlayerStats"> | number
noScopes?: IntWithAggregatesFilter<"PlayerStats"> | number noScopes?: IntWithAggregatesFilter<"PlayerStats"> | number
blindKills?: IntWithAggregatesFilter<"PlayerStats"> | number blindKills?: IntWithAggregatesFilter<"PlayerStats"> | number
k1?: IntWithAggregatesFilter<"PlayerStats"> | number aim?: IntWithAggregatesFilter<"PlayerStats"> | number
k2?: IntWithAggregatesFilter<"PlayerStats"> | number oneK?: IntWithAggregatesFilter<"PlayerStats"> | number
k3?: IntWithAggregatesFilter<"PlayerStats"> | number twoK?: IntWithAggregatesFilter<"PlayerStats"> | number
k4?: IntWithAggregatesFilter<"PlayerStats"> | number threeK?: IntWithAggregatesFilter<"PlayerStats"> | number
k5?: IntWithAggregatesFilter<"PlayerStats"> | number fourK?: IntWithAggregatesFilter<"PlayerStats"> | number
fiveK?: IntWithAggregatesFilter<"PlayerStats"> | number
rankOld?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null rankOld?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null
rankNew?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null rankNew?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null
rankChange?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null rankChange?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null
@ -17803,11 +17826,12 @@ export namespace Prisma {
headshots?: number headshots?: number
noScopes?: number noScopes?: number
blindKills?: number blindKills?: number
k1?: number aim?: number
k2?: number oneK?: number
k3?: number twoK?: number
k4?: number threeK?: number
k5?: number fourK?: number
fiveK?: number
rankOld?: number | null rankOld?: number | null
rankNew?: number | null rankNew?: number | null
rankChange?: number | null rankChange?: number | null
@ -17837,11 +17861,12 @@ export namespace Prisma {
headshots?: number headshots?: number
noScopes?: number noScopes?: number
blindKills?: number blindKills?: number
k1?: number aim?: number
k2?: number oneK?: number
k3?: number twoK?: number
k4?: number threeK?: number
k5?: number fourK?: number
fiveK?: number
rankOld?: number | null rankOld?: number | null
rankNew?: number | null rankNew?: number | null
rankChange?: number | null rankChange?: number | null
@ -17868,11 +17893,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number aim?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number oneK?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number twoK?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number threeK?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -17902,11 +17928,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number aim?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number oneK?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number twoK?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number threeK?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -17935,11 +17962,12 @@ export namespace Prisma {
headshots?: number headshots?: number
noScopes?: number noScopes?: number
blindKills?: number blindKills?: number
k1?: number aim?: number
k2?: number oneK?: number
k3?: number twoK?: number
k4?: number threeK?: number
k5?: number fourK?: number
fiveK?: number
rankOld?: number | null rankOld?: number | null
rankNew?: number | null rankNew?: number | null
rankChange?: number | null rankChange?: number | null
@ -17966,11 +17994,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number aim?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number oneK?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number twoK?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number threeK?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -17999,11 +18028,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number aim?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number oneK?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number twoK?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number threeK?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -18971,11 +19001,12 @@ export namespace Prisma {
headshots?: SortOrder headshots?: SortOrder
noScopes?: SortOrder noScopes?: SortOrder
blindKills?: SortOrder blindKills?: SortOrder
k1?: SortOrder aim?: SortOrder
k2?: SortOrder oneK?: SortOrder
k3?: SortOrder twoK?: SortOrder
k4?: SortOrder threeK?: SortOrder
k5?: SortOrder fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder rankOld?: SortOrder
rankNew?: SortOrder rankNew?: SortOrder
rankChange?: SortOrder rankChange?: SortOrder
@ -19001,11 +19032,12 @@ export namespace Prisma {
headshots?: SortOrder headshots?: SortOrder
noScopes?: SortOrder noScopes?: SortOrder
blindKills?: SortOrder blindKills?: SortOrder
k1?: SortOrder aim?: SortOrder
k2?: SortOrder oneK?: SortOrder
k3?: SortOrder twoK?: SortOrder
k4?: SortOrder threeK?: SortOrder
k5?: SortOrder fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder rankOld?: SortOrder
rankNew?: SortOrder rankNew?: SortOrder
rankChange?: SortOrder rankChange?: SortOrder
@ -19034,11 +19066,12 @@ export namespace Prisma {
headshots?: SortOrder headshots?: SortOrder
noScopes?: SortOrder noScopes?: SortOrder
blindKills?: SortOrder blindKills?: SortOrder
k1?: SortOrder aim?: SortOrder
k2?: SortOrder oneK?: SortOrder
k3?: SortOrder twoK?: SortOrder
k4?: SortOrder threeK?: SortOrder
k5?: SortOrder fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder rankOld?: SortOrder
rankNew?: SortOrder rankNew?: SortOrder
rankChange?: SortOrder rankChange?: SortOrder
@ -19067,11 +19100,12 @@ export namespace Prisma {
headshots?: SortOrder headshots?: SortOrder
noScopes?: SortOrder noScopes?: SortOrder
blindKills?: SortOrder blindKills?: SortOrder
k1?: SortOrder aim?: SortOrder
k2?: SortOrder oneK?: SortOrder
k3?: SortOrder twoK?: SortOrder
k4?: SortOrder threeK?: SortOrder
k5?: SortOrder fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder rankOld?: SortOrder
rankNew?: SortOrder rankNew?: SortOrder
rankChange?: SortOrder rankChange?: SortOrder
@ -19097,11 +19131,12 @@ export namespace Prisma {
headshots?: SortOrder headshots?: SortOrder
noScopes?: SortOrder noScopes?: SortOrder
blindKills?: SortOrder blindKills?: SortOrder
k1?: SortOrder aim?: SortOrder
k2?: SortOrder oneK?: SortOrder
k3?: SortOrder twoK?: SortOrder
k4?: SortOrder threeK?: SortOrder
k5?: SortOrder fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder rankOld?: SortOrder
rankNew?: SortOrder rankNew?: SortOrder
rankChange?: SortOrder rankChange?: SortOrder
@ -23360,11 +23395,12 @@ export namespace Prisma {
headshots?: number headshots?: number
noScopes?: number noScopes?: number
blindKills?: number blindKills?: number
k1?: number aim?: number
k2?: number oneK?: number
k3?: number twoK?: number
k4?: number threeK?: number
k5?: number fourK?: number
fiveK?: number
rankOld?: number | null rankOld?: number | null
rankNew?: number | null rankNew?: number | null
rankChange?: number | null rankChange?: number | null
@ -23391,11 +23427,12 @@ export namespace Prisma {
headshots?: number headshots?: number
noScopes?: number noScopes?: number
blindKills?: number blindKills?: number
k1?: number aim?: number
k2?: number oneK?: number
k3?: number twoK?: number
k4?: number threeK?: number
k5?: number fourK?: number
fiveK?: number
rankOld?: number | null rankOld?: number | null
rankNew?: number | null rankNew?: number | null
rankChange?: number | null rankChange?: number | null
@ -23601,11 +23638,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number aim?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number oneK?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number twoK?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number threeK?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -23632,11 +23670,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number aim?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number oneK?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number twoK?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number threeK?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null rankChange?: NullableIntFieldUpdateOperationsInput | number | null

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"name": "prisma-client-42c2f4122e5c92abceced92f5ccc724f4e2068f151760c3339b2243eb9d75d8e", "name": "prisma-client-606d0d92f2bc1947c35b0ceba01327a22f26543cd49f6fab394887ba3b7b7804",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",

View File

@ -176,11 +176,13 @@ model PlayerStats {
noScopes Int @default(0) noScopes Int @default(0)
blindKills Int @default(0) blindKills Int @default(0)
k1 Int @default(0) aim Int @default(0)
k2 Int @default(0)
k3 Int @default(0) oneK Int @default(0)
k4 Int @default(0) twoK Int @default(0)
k5 Int @default(0) threeK Int @default(0)
fourK Int @default(0)
fiveK Int @default(0)
rankOld Int? rankOld Int?
rankNew Int? rankNew Int?

View File

@ -214,11 +214,12 @@ exports.Prisma.PlayerStatsScalarFieldEnum = {
headshots: 'headshots', headshots: 'headshots',
noScopes: 'noScopes', noScopes: 'noScopes',
blindKills: 'blindKills', blindKills: 'blindKills',
k1: 'k1', aim: 'aim',
k2: 'k2', oneK: 'oneK',
k3: 'k3', twoK: 'twoK',
k4: 'k4', threeK: 'threeK',
k5: 'k5', fourK: 'fourK',
fiveK: 'fiveK',
rankOld: 'rankOld', rankOld: 'rankOld',
rankNew: 'rankNew', rankNew: 'rankNew',
rankChange: 'rankChange', rankChange: 'rankChange',

View File

@ -32,11 +32,12 @@ interface PlayerStatsExtended {
rankNew?: number; rankNew?: number;
rankChange?: number; rankChange?: number;
winCount?: number; winCount?: number;
k1?: number, aim?: number,
k2?: number, oneK?: number,
k3?: number, twoK?: number,
k4?: number, threeK?: number,
k5?: number, fourK?: number,
fiveK?: number,
} }
interface DemoMatchData { interface DemoMatchData {
@ -272,11 +273,12 @@ export async function parseAndStoreDemo(
rankNew: player.rankNew ?? null, rankNew: player.rankNew ?? null,
rankChange: player.rankChange ?? null, rankChange: player.rankChange ?? null,
winCount: player.winCount ?? null, winCount: player.winCount ?? null,
k1: player.k1 ?? 0, aim: player.aim ?? 0,
k2: player.k2 ?? 0, oneK: player.oneK ?? 0,
k3: player.k3 ?? 0, twoK: player.twoK ?? 0,
k4: player.k4 ?? 0, threeK: player.threeK ?? 0,
k5: player.k5 ?? 0, fourK: player.fourK ?? 0,
fiveK: player.fiveK ?? 0,
}, },
create: { create: {
id: matchPlayer.id, id: matchPlayer.id,
@ -304,11 +306,12 @@ export async function parseAndStoreDemo(
rankNew: player.rankNew ?? null, rankNew: player.rankNew ?? null,
rankChange: player.rankChange ?? null, rankChange: player.rankChange ?? null,
winCount: player.winCount ?? null, winCount: player.winCount ?? null,
k1: player.k1 ?? 0, aim: player.aim ?? 0,
k2: player.k2 ?? 0, oneK: player.oneK ?? 0,
k3: player.k3 ?? 0, twoK: player.twoK ?? 0,
k4: player.k4 ?? 0, threeK: player.threeK ?? 0,
k5: player.k5 ?? 0, fourK: player.fourK ?? 0,
fiveK: player.fiveK ?? 0,
}, },
}); });
} }

View File

@ -1,71 +1,146 @@
import fs from 'fs/promises'; import fs from 'fs/promises'
import path from 'path'; import path from 'path'
import { Match, User } from '@/generated/prisma'; import type { Match, User } from '@/generated/prisma'
import { parseAndStoreDemo } from './parseAndStoreDemo'; import { parseAndStoreDemo } from './parseAndStoreDemo'
import { log } from '../../scripts/cs2-cron-runner.js'; import { log } from '../../scripts/cs2-cron-runner.js'
import { prisma } from '../app/lib/prisma.js'; import { prisma } from '../app/lib/prisma.js'
export async function runDownloaderForUser(user: User): Promise<{ type DownloadResponse = {
newMatches: Match[]; success: boolean
latestShareCode: string | null; path?: string
}> { matchId?: string
if (!user.authCode || !user.lastKnownShareCode) { error?: string
throw new Error(`User ${user.steamId}: authCode oder ShareCode fehlt`);
} }
const steamId = user.steamId; const isWinAbs = (p: string) => /^[a-zA-Z]:\\/.test(p)
const shareCode = user.lastKnownShareCode; const isUnixAbs = (p: string) => p.startsWith('/')
log(`[${user.steamId}] 📥 Lade Demo herunter...`); /** Extrahiert matchId aus einem Dateinamen als Fallback (falls der Downloader sie nicht mitliefert). */
const extractMatchIdFromName = (name: string): string | null => {
// Beispiele: match730_de_inferno_3762944197338858088_competitive.dem
const m = name.match(/match\d+_[^_]+_(\d+)_/)
return m?.[1] ?? null
}
// 🎯 Nur HTTP-Modus export async function runDownloaderForUser(user: User): Promise<{
newMatches: Match[]
latestShareCode: string | null
}> {
if (!user.authCode || !user.lastKnownShareCode) {
throw new Error(`User ${user.steamId}: authCode oder ShareCode fehlt`)
}
const steamId = user.steamId
const shareCode = user.lastKnownShareCode
log(`[${steamId}] 📥 Lade Demo herunter...`)
// ───────────────────────── HTTP-Aufruf an Downloader ─────────────────────────
let data: DownloadResponse
try {
const res = await fetch('http://localhost:4000/download', { const res = await fetch('http://localhost:4000/download', {
method : 'POST', method : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ steamId, shareCode }), body : JSON.stringify({ steamId, shareCode }),
}); })
const data = await res.json(); if (!res.ok) {
const text = await res.text().catch(() => '')
if (!data.success) { log(`[${steamId}] ❌ Downloader HTTP ${res.status}: ${text || res.statusText}`, 'error')
log(`[${steamId}] ❌ Downloader-Fehler: ${data.error}`, 'error'); return { newMatches: [], latestShareCode: shareCode }
} }
const demoPath = data.path; data = (await res.json()) as DownloadResponse
} catch (err: any) {
log(`[${steamId}] ❌ Downloader-Netzwerkfehler: ${err?.message ?? String(err)}`, 'error')
return { newMatches: [], latestShareCode: shareCode }
}
if (!data?.success) {
log(`[${steamId}] ❌ Downloader-Fehler: ${data?.error ?? 'unbekannt'}`, 'error')
return { newMatches: [], latestShareCode: shareCode }
}
let demoPath: string | undefined = data.path
const matchIdFromResp: string | undefined = data.matchId ?? undefined
if (!demoPath) { if (!demoPath) {
log(`[${steamId}] ⚠️ Kein Demo-Pfad erhalten Match wird übersprungen`, 'warn'); log(`[${steamId}] ⚠️ Kein Demo-Pfad erhalten Match wird übersprungen`, 'warn')
return { newMatches: [], latestShareCode: shareCode }; return { newMatches: [], latestShareCode: shareCode }
} }
const filename = path.basename(demoPath); // ───────────────────────── Pfad plattformneutral absolut machen ─────────────
const matchId = filename.replace(/\.dem$/, ''); let absolutePath = (isWinAbs(demoPath) || isUnixAbs(demoPath))
? demoPath
: path.resolve(process.cwd(), demoPath) // falls relativ geliefert
const existing = await prisma.match.findUnique({ // ───────────────────────── Existenz prüfen; ggf. Fallback mit matchId ───────
where: { id: matchId }, try {
}); await fs.access(absolutePath)
} catch {
// Datei fehlt evtl. anderer Mapname im Dateinamen. Versuche, anhand matchId zu finden.
const dir = path.dirname(absolutePath)
const justName = demoPath.split(/[/\\]/).pop() ?? ''
const fallbackMatchId = matchIdFromResp ?? extractMatchIdFromName(justName) ?? ''
if (existing) { try {
log(`[${steamId}] 🔁 Match ${matchId} wurde bereits analysiert übersprungen`, 'info'); const entries = await fs.readdir(dir)
return { newMatches: [], latestShareCode: shareCode }; const hit = entries.find(n =>
} n.endsWith('.dem') &&
(fallbackMatchId ? n.includes(`_${fallbackMatchId}_`) : false),
)
log(`[${steamId}] 📂 Analysiere: ${filename}`); if (hit) {
absolutePath = path.join(dir, hit)
const absolutePath = path.resolve(__dirname, '../../../cs2-demo-downloader', demoPath); log(`[${steamId}] 🔎 Pfad korrigiert: ${absolutePath}`, 'info')
const match = await parseAndStoreDemo(absolutePath, steamId, shareCode);
const newMatches: Match[] = [];
if (match) {
newMatches.push(match);
log(`[${steamId}] ✅ Match gespeichert: ${match.id}`);
} else { } else {
log(`[${steamId}] ⚠️ Match bereits vorhanden oder Analyse fehlgeschlagen`, 'warn'); log(`[${steamId}] ⚠️ Datei nicht gefunden: ${absolutePath}`, 'warn')
return { newMatches: [], latestShareCode: shareCode }
}
} catch (e) {
log(`[${steamId}] ⚠️ Verzeichnis nicht lesbar: ${dir}`, 'warn')
return { newMatches: [], latestShareCode: shareCode }
}
}
// ───────────────────────── matchId bestimmen (DB-Duplikat-Check) ────────────
const matchId =
matchIdFromResp ??
extractMatchIdFromName(demoPath.split(/[/\\]/).pop() ?? '') ??
''
if (!matchId) {
log(`[${steamId}] ⚠️ Konnte matchId nicht ermitteln übersprungen`, 'warn')
return { newMatches: [], latestShareCode: shareCode }
}
const existsInDb = await prisma.match.findUnique({ where: { id: matchId } })
if (existsInDb) {
log(`[${steamId}] 🔁 Match ${matchId} wurde bereits analysiert übersprungen`, 'info')
return { newMatches: [], latestShareCode: shareCode }
}
const filename = absolutePath.split(/[/\\]/).pop() ?? 'demo.dem'
log(`[${steamId}] 📂 Analysiere: ${filename}`)
// ───────────────────────── Parser starten ───────────────────────────────────
let match: Match | null = null
try {
match = await parseAndStoreDemo(absolutePath, steamId, shareCode)
} catch (err: any) {
log(`[${steamId}] ❌ Analysefehler: ${err?.message ?? String(err)}`, 'error')
}
const newMatches: Match[] = []
if (match) {
newMatches.push(match)
log(`[${steamId}] ✅ Match gespeichert: ${match.id}`)
} else {
log(`[${steamId}] ⚠️ Match bereits vorhanden oder Analyse fehlgeschlagen`, 'warn')
} }
return { return {
newMatches, newMatches,
latestShareCode: shareCode, latestShareCode: shareCode,
}; }
} }