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

@ -175,12 +175,14 @@ model PlayerStats {
headshots Int @default(0)
noScopes Int @default(0)
blindKills Int @default(0)
aim Int @default(0)
k1 Int @default(0)
k2 Int @default(0)
k3 Int @default(0)
k4 Int @default(0)
k5 Int @default(0)
oneK Int @default(0)
twoK Int @default(0)
threeK Int @default(0)
fourK Int @default(0)
fiveK Int @default(0)
rankOld 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 { authOptions } from '@/app/lib/auth'
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) {
/* ── Auth ▸ nur Admins ───────────────────────────── */
// ── Auth: nur Admins
const session = await getServerSession(authOptions(req))
if (!session?.user?.isAdmin)
if (!session?.user?.isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
/* ── Body auslesen ──────────────────────────────── */
const { teamAId, teamBId, title, description, matchDate, map } = await req.json()
if (!teamAId || !teamBId || !matchDate)
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
// ── Body
const body = await req.json().catch(() => ({}))
const {
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([
prisma.team.findUnique({ where: { id: teamAId }, select: { activePlayers: true } }),
prisma.team.findUnique({ where: { id: teamBId }, select: { activePlayers: true } }),
prisma.team.findUnique({
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 })
}
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 {
const result = await prisma.$transaction(async (tx) => {
/* 1) Match mit verbundenen Team-User-Arrays anlegen */
// ── Anlegen in Transaktion
const created = await prisma.$transaction(async (tx) => {
const newMatch = await tx.match.create({
data: {
teamAId,
teamBId,
title : title?.trim() || `${teamAId}-${teamBId}`,
description : description?.trim() || null,
map : map?.trim() || null,
demoDate : new Date(matchDate),
/* aktive Spieler direkt verbinden */
teamAUsers: { connect: teamA.activePlayers.map(id => ({ steamId: id })) },
teamBUsers: { connect: teamB.activePlayers.map(id => ({ steamId: id })) },
title : safeTitle,
description : safeDesc,
map : safeMap,
demoDate : plannedAt,
// ⚠ hier KEIN "type" setzen existiert nicht im Schema
teamAUsers : { connect: (teamA.activePlayers ?? []).map(id => ({ steamId: id })) },
teamBUsers : { connect: (teamB.activePlayers ?? []).map(id => ({ steamId: id })) },
},
})
/* 2) separate MatchPlayer-Zeilen */
const playersData = [
...teamA.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamAId })),
...teamB.activePlayers.map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamBId })),
...(teamA.activePlayers ?? []).map(id => ({ matchId: newMatch.id, steamId: id, teamId: teamAId })),
...(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 })
}
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) {
console.error('POST /matches/create failed:', err)
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'
export async function GET() {
export async function GET(req: NextRequest) {
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({
where: teamId ? { teamId } : undefined,
include: {
user: {
select: {
@ -19,31 +24,23 @@ export async function GET() {
},
})
// 2. Steam-IDs aus Einladungen extrahieren
const invitedSteamIds = new Set(
pendingInvites
.map(inv => inv.user?.steamId)
.filter(Boolean)
// 2) Nur die von DIESEM Team bereits eingeladenen Steam-IDs
const invitedByThisTeam = new Set(
pendingInvites.map(inv => inv.user?.steamId).filter(Boolean) as string[]
)
// 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({
select: {
activePlayers: true,
inactivePlayers: true,
},
select: { activePlayers: true, inactivePlayers: true },
})
// 4. Steam-IDs aller Teammitglieder sammeln
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({
where: {
team: null,
},
where: { team: null }, // hat noch kein Team
select: {
steamId : true,
name : true,
@ -51,15 +48,13 @@ export async function GET() {
location : true,
premierRank: true,
},
orderBy: {
name: 'asc',
},
orderBy: { name: 'asc' },
})
// 6. Nur Benutzer behalten, die **nicht** eingeladen und **nicht** bereits im Team sind
const availableUsers = allUsers.filter(user =>
!invitedSteamIds.has(user.steamId) &&
!teamMemberIds.has(user.steamId)
// 5) Verfügbar = kein Mitglied + NICHT bereits von DIESEM Team eingeladen
const availableUsers = allUsers.filter(u =>
!teamMemberIds.has(u.steamId) &&
!invitedByThisTeam.has(u.steamId)
)
return NextResponse.json({ users: availableUsers })

View File

@ -1,19 +1,20 @@
// /api/team/create/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
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) {
try {
/* ───── Request-Body ───── */
const { teamname, leader }: { teamname?: string; leader?: string } = await req.json()
/* ► Teamname pflicht */
if (!teamname?.trim()) {
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 } })
if (dup) {
return NextResponse.json({ message: 'Teamname bereits vergeben.' }, { status: 400 })
@ -22,46 +23,74 @@ export async function POST(req: NextRequest) {
/* ───── Team anlegen ───── */
const newTeam = await prisma.team.create({
data: {
name : teamname,
leaderId : leader ?? null, // ← nur setzen, wenn übergeben
activePlayers : leader ? [leader] : [],
inactivePlayers : [],
name: teamname,
leaderId: leader ?? null,
activePlayers: leader ? [leader] : [],
inactivePlayers: [],
},
})
/* ───── Optional: Leader verknüpfen ───── */
/* ───── Leader verknüpfen + Notification ───── */
if (leader) {
const user = await prisma.user.findUnique({ where: { steamId: leader } })
if (!user) {
// Rollback Team und Fehler ausgeben
// Rollback Team und Fehler
await prisma.team.delete({ where: { id: newTeam.id } })
return NextResponse.json({ message: 'Leader-Benutzer nicht gefunden.' }, { status: 404 })
}
// User an Team hängen
await prisma.user.update({
where: { steamId: leader },
data : { teamId: newTeam.id },
data: { teamId: newTeam.id },
})
await prisma.notification.create({
// Persistente Notification
const note = await prisma.notification.create({
data: {
steamId: leader,
title : 'Team erstellt',
title: 'Team 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({
type : 'team-created',
title : 'Team erstellt',
type: 'team-created',
title: 'Team erstellt',
message: `Das Team „${teamname}“ wurde erstellt.`,
teamId : newTeam.id,
teamId: newTeam.id,
})
return NextResponse.json({ message: 'Team erstellt', team: newTeam })
} catch (error) {
/* ───── Failsafe/Listen-Refresh für alle Clients ───── */
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)
return NextResponse.json({ message: 'Interner Serverfehler.' }, { status: 500 })
}

View File

@ -1,17 +1,137 @@
// /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 { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export async function POST(req: Request) {
const body = await req.json()
const { teamId } = body
if (!teamId) {
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
}
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
await prisma.team.delete({ where: { id: teamId } })
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) {
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
}
// Team laden (für Berechtigung + spätere Benachrichtigung)
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 })
} catch (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 })
}
// Team + Member laden (für Zielgruppe)
// Team + Members laden (für Notifications)
const teamBefore = await prisma.team.findUnique({
where: { id: teamId },
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 })
}
// umbenennen (Unique-Name beachten)
// Umbenennen (Unique-Constraint beachten)
let updated
try {
updated = await prisma.team.update({
@ -38,7 +38,7 @@ export async function POST(req: NextRequest) {
throw e
}
// Zielnutzer (Leader + aktive + inaktive)
// Zielnutzer (Leader + aktive + inaktive) für persistente Notifications
const targets = Array.from(new Set(
[
updated.leaderId,
@ -49,7 +49,7 @@ export async function POST(req: NextRequest) {
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) {
const created = await Promise.all(
targets.map(steamId =>
@ -65,7 +65,6 @@ export async function POST(req: NextRequest) {
)
)
// live zustellen als sichtbare Notification
await Promise.all(
created.map(n =>
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({
type: 'team-renamed',
teamId,
targetUserIds: targets,
message: text,
newName: updated.name,
})
// Generisches Reload-Signal (failsafe)
// Optionaler Failsafe-Reload als Broadcast
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds: targets,
})
return NextResponse.json(

View File

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

View File

@ -11,60 +11,109 @@ export async function POST(
) {
try {
const { action } = params
const { invitationId } = await req.json()
const body = await req.json().catch(() => ({} as any))
if (!invitationId) {
return NextResponse.json({ message: 'Invitation ID fehlt' }, { status: 400 })
}
// NEU: neben invitationId auch teamId+steamId als Fallback akzeptieren
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) {
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') {
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 result = await prisma.$transaction(async (tx) => {
const teamBefore = await tx.team.findUnique({
where: { id: teamId },
select: { id: true, name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
if (!teamBefore) throw new Error('Team nicht gefunden')
const teamBefore = await prisma.team.findUnique({
where: { id: teamId },
select: { name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
// 1) User ins Team hängen
await tx.user.update({
where: { steamId: invitedUserSteamId },
data: { teamId },
})
// 2) Inaktive Liste updaten (ohne Duplikate)
const nextInactive = Array.from(new Set([...(teamBefore.inactivePlayers ?? []), invitedUserSteamId]))
await tx.team.update({
where: { id: teamId },
data: { inactivePlayers: nextInactive },
})
// 3) Angenommene Einladung löschen + Notifications bereinigen
await tx.teamInvite.delete({ where: { id: invitationId } })
await tx.notification.updateMany({
where: { actionData: invitationId },
data: { read: true, actionType: null, actionData: null },
})
// 4) Andere offenen Team-Einladungen für diesen User entfernen
const otherInvites = await tx.teamInvite.findMany({
where: {
steamId: invitedUserSteamId,
type: 'team-invite',
NOT: { id: invitationId },
},
select: { id: true, teamId: true },
})
if (otherInvites.length) {
await tx.teamInvite.deleteMany({ where: { id: { in: otherInvites.map(o => o.id) } } })
await tx.notification.updateMany({
where: { actionData: { in: otherInvites.map(o => o.id) } },
data: { read: true, actionType: null, actionData: null },
})
}
return { teamBefore, nextInactive, otherInvites }
})
const nextInactive = Array.from(new Set([...(teamBefore?.inactivePlayers ?? []), invitedUserSteamId]))
await prisma.team.update({
where: { id: teamId },
data: { inactivePlayers: nextInactive },
})
await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({
where: { actionData: invitationId },
data: { read: true, actionType: null, actionData: null },
})
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { name: true, leaderId: true, activePlayers: true, inactivePlayers: true },
})
const allMembers = Array.from(
new Set(
[
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
].filter(Boolean) as string[]
)
)
// 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({
data: {
steamId: invitedUserSteamId,
title: 'Teambeitritt',
message: `Du bist dem Team "${team?.name ?? 'Unbekannt'}" beigetreten.`,
message: `Du bist dem Team "${result.teamBefore.name ?? 'Unbekannt'}" beigetreten.`,
actionType: 'team-joined',
actionData: teamId,
},
@ -79,12 +128,13 @@ export async function POST(
createdAt: joinedNotif.createdAt.toISOString(),
})
const joiningUser = await prisma.user.findUnique({
where: { steamId: invitedUserSteamId },
select: { name: true },
})
// Info an die übrigen Teammitglieder
const others = allMembers.filter(id => id !== invitedUserSteamId)
if (others.length) {
const joiningUser = await prisma.user.findUnique({
where: { steamId: invitedUserSteamId },
select: { name: true },
})
const created = await Promise.all(
others.map(uid =>
prisma.notification.create({
@ -113,41 +163,76 @@ export async function POST(
)
}
// Soft-Reload fürs beigetretene Team
await sendServerSSEMessage({
type: 'team-updated',
teamId,
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' })
}
/* ───────────────────────────── REJECT ──────────────────────────── */
if (action === 'reject') {
// Einladung löschen & zugehörige Notifications aufräumen (keine sichtbare Nachricht)
await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({
where: { actionData: invitationId },
data: { read: true, actionType: null, actionData: null },
})
// ➜ Team-Mitglieder ermitteln (Leader + aktive + inaktive), ohne die eingeladene Person
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
})
const remainingMembers = Array.from(
new Set(
[
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
]
.filter(Boolean) as string[]
)
).filter(id => id !== invitedUserSteamId)
const remainingMembers = Array.from(new Set(
[
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
].filter(Boolean) as string[]
)).filter(id => id !== invitedUserSteamId)
// ➜ Silent UI Refresh via SSE für die verbleibenden Mitglieder
if (remainingMembers.length) {
await sendServerSSEMessage({
type: 'team-updated',
@ -159,6 +244,7 @@ export async function POST(
return NextResponse.json({ message: 'Einladung abgelehnt' })
}
/* ───────────────────────────── REVOKE ──────────────────────────── */
if (action === 'revoke') {
await prisma.teamInvite.delete({ where: { id: invitationId } })
await prisma.notification.updateMany({
@ -170,29 +256,35 @@ export async function POST(
where: { id: teamId },
select: { leaderId: true, activePlayers: true, inactivePlayers: true },
})
const admins = await prisma.user.findMany({
where: { isAdmin: true },
select: { steamId: true },
})
const targetUserIds = Array.from(
new Set(
[
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
...admins.map(a => a.steamId),
].filter(Boolean) as string[]
)
)
const targetUserIds = Array.from(new Set([
team?.leaderId,
...(team?.activePlayers ?? []),
...(team?.inactivePlayers ?? []),
...admins.map(a => a.steamId),
].filter(Boolean) as string[]))
// Eingeladenen informieren, damit die Einladung sofort verschwindet
await sendServerSSEMessage({
type: 'team-updated',
type: 'team-invite-revoked',
targetUserIds: [invitedUserSteamId],
invitationId,
teamId,
targetUserIds,
})
// Team-UI refreshen
if (targetUserIds.length) {
await sendServerSSEMessage({
type: 'team-updated',
teamId,
targetUserIds,
})
}
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 { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
@ -18,6 +20,7 @@ export async function GET(req: NextRequest) {
steamId: true,
avatar: true,
team: true,
premierRank: true,
isAdmin: true,
},
})

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
@ -9,23 +9,37 @@ import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Switch from '@/app/components/Switch'
import Button from './Button'
import Modal from './Modal'
import { Match } from '../types/match'
import { differenceInMinutes } from 'date-fns'
type Props = { matchType?: string }
/* ------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------ */
const getTeamLogo = (logo?: string | null) =>
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
const toDateKey = (d: Date) => d.toISOString().slice(0, 10)
const weekdayDE = new Intl.DateTimeFormat('de-DE', { weekday: 'long' })
/* ------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------ */
type TeamOption = { id: string; name: string; logo?: string | null }
/** 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) {
const { data: session } = useSession()
const router = useRouter()
@ -33,34 +47,141 @@ export default function CommunityMatchList({ matchType }: Props) {
const [matches, setMatches] = useState<Match[]>([])
const [onlyOwn, setOnlyOwn] = useState(false)
/* Daten laden */
useEffect(() => {
const url = `/api/matches${matchType ? `?type=${encodeURIComponent(matchType)}` : ''}`
// Modal-States
const [showCreate, setShowCreate] = useState(false)
const [teams, setTeams] = useState<TeamOption[]>([])
const [loadingTeams, setLoadingTeams] = useState(false)
const [saving, setSaving] = useState(false)
fetch(url)
.then(r => (r.ok ? r.json() : []))
.then(setMatches)
.catch(err => console.error('[MatchList] Laden fehlgeschlagen:', err))
const [teamAId, setTeamAId] = useState<string>('')
const [teamBId, setTeamBId] = useState<string>('')
const [title, setTitle] = useState<string>('') // auto editierbar
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])
/* 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 sorted = [...matches].sort(
(a, b) => new Date(a.demoDate).getTime() - new Date(b.demoDate).getTime(),
)
const map = new Map<string, Match[]>()
for (const m of sorted) {
const key = toDateKey(new Date(m.demoDate))
map.set(key, [...(map.get(key) ?? []), m])
}
return Array.from(map.entries()) // [ [ '2025-08-28', [ … ] ], … ]
return Array.from(map.entries())
})()
/* Render */
return (
<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">
<h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300">
Geplante Matches
@ -72,16 +193,15 @@ export default function CommunityMatchList({ matchType }: Props) {
onChange={setOnlyOwn}
labelRight="Nur mein Team anzeigen"
/>
{session?.user?.isAdmin && (
<Link href="/admin/matches">
<Button color="blue" onClick={() => router.push(`/admin/matches`)}>Match erstellen</Button>
</Link>
<Button color="blue" onClick={() => setShowCreate(true)}>
Match erstellen
</Button>
)}
</div>
</div>
{/* Inhalt ------------------------------------------------------- */}
{/* Inhalt */}
{grouped.length === 0 ? (
<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) => {
const dateObj = new Date(dateKey + 'T00:00:00')
const dayLabel = `Tag #${dayIdx + 1} ${weekdayDE.format(dateObj)}`
return (
<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">
{dayLabel}<br />
{dateKey}
{dayLabel}<br />{dateKey}
</div>
{/* Matches des Tages */}
{dayMatches.map(m => {
/* 1⃣ Regeln --------------------------------------------- */
const demoDate = new Date(m.demoDate)
const started = demoDate <= Date.now()
const started = new Date(m.demoDate).getTime() <= Date.now()
const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null
const isLive = started && unfinished // ← live-Flag
const isOwnTeam =
session?.user?.team &&
(m.teamA.id === session.user.team || m.teamB.id === session.user.team)
/* Wenn nur-Own aktiv & nicht eigenes Match → abdunkeln */
const isLive = started && unfinished
const isOwnTeam = !!session?.user?.team &&
(m.teamA.id === session.user.team || m.teamB.id === session.user.team)
const dimmed = onlyOwn && !isOwnTeam
return (
<Link
key={m.id}
href={`/match-details/${m.id}`}
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
hover:scale-105 hover:bg-neutral-400 hover:dark:bg-neutral-700
hover:shadow-md transition-transform h-[172px]
${dimmed ? 'opacity-40' : ''}
hover:shadow-md h-[172px]
transition-transform transition-opacity duration-300 ease-in-out
${dimmed ? 'opacity-40' : 'opacity-100'}
`}
>
{/** ⏱ kleine Live-Marke, falls gewünscht */}
{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
</span>
)}
{/* Teams -------------------------------------------------- */}
<div className="flex w-full justify-around items-center">
{/* Team A */}
<div className="flex flex-col items-center w-1/3">
<Image
src={getTeamLogo(m.teamA.logo)}
alt={m.teamA.name}
width={48}
height={48}
className="rounded-full border bg-white"
/>
<Image 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>
</div>
{/* vs */}
<span className="font-bold">vs</span>
{/* Team B */}
<div className="flex flex-col items-center w-1/3">
<Image
src={getTeamLogo(m.teamB.logo)}
alt={m.teamB.name}
width={48}
height={48}
className="rounded-full border bg-white"
/>
<Image 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>
</div>
</div>
{/* Datum + Uhrzeit --------------------------------------- */}
<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'}`}>
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
</span>
{/* Zeit */}
<span className="flex items-center gap-1 text-xs opacity-80">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-3.5 h-3.5"
fill="currentColor"
viewBox="0 0 512 512"
>
<svg 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" />
</svg>
{format(new Date(m.demoDate), 'HH:mm', { locale: de })} Uhr
@ -192,6 +271,127 @@ export default function CommunityMatchList({ matchType }: Props) {
})}
</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>
)
}

View File

@ -4,7 +4,6 @@ import { useEffect, useState, useImperativeHandle, forwardRef } from 'react'
import { useSession } from 'next-auth/react'
import Modal from './Modal'
import Button from './Button'
import { Player, Team } from '../types/team'
type CreateTeamButtonProps = {
setRefetchKey: (key: string) => void
@ -18,6 +17,32 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
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 () => {
setStatus('idle')
setMessage('')
@ -36,25 +61,18 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
})
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')
setMessage(`Team "${result.team.name}" wurde erfolgreich erstellt!`)
setTeamname('')
// 🔒 Nach kurzer Bestätigung Modal schließen, Backdrop bereinigen und Liste refreshen
setTimeout(() => {
const modalEl = document.getElementById('modal-create-team')
if (modalEl && window.HSOverlay?.close) {
window.HSOverlay.close(modalEl)
}
setShowModal(false)
setRefetchKey(Date.now().toString())
}, 1500)
closeCreateModalAndCleanup()
// einen Tick warten, bis DOM frei ist
requestAnimationFrame(() => setRefetchKey(Date.now().toString()))
}, 800) // kürzer reicht idR, 800ms für Feedback
} catch (err: any) {
setStatus('error')
setMessage(err.message || 'Fehler beim Erstellen des Teams')
@ -63,12 +81,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
return (
<div>
<Button
onClick={() => setShowModal(true)}
color="blue"
variant="solid"
size="sm"
>
<Button onClick={() => setShowModal(true)} color="blue" variant="solid" size="sm">
Neues Team erstellen
</Button>
@ -76,7 +89,11 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
id="modal-create-team"
title="Neues Team erstellen"
show={showModal}
onClose={() => setShowModal(false)}
onClose={() => {
setShowModal(false)
// Falls Benutzer per X schließt auch dann cleanen
closeCreateModalAndCleanup()
}}
onSave={handleSubmit}
closeButtonTitle="Team erstellen"
>
@ -95,14 +112,14 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
setMessage('')
}}
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
${
status === 'error'
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: status === 'success'
? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}
className={`
py-2.5 sm:py-3 px-4 block w-full rounded-lg sm:text-sm
dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 dark:placeholder-neutral-500 dark:focus:ring-neutral-600
${status === 'error'
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: status === 'success'
? 'border-teal-500 focus:border-teal-500 focus:ring-teal-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'}
`}
required
name="teamname"
@ -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">
<svg
className={`shrink-0 size-4 ${status === 'error' ? 'text-red-500' : 'text-teal-500'}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
{status === 'error' ? (
<>
@ -134,9 +148,7 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
{message && (
<p
id="teamname-feedback"
className={`text-sm mt-1 ${
status === 'error' ? 'text-red-600' : 'text-teal-600'
}`}
className={`text-sm mt-1 ${status === 'error' ? 'text-red-600' : 'text-teal-600'}`}
>
{message}
</p>
@ -148,5 +160,4 @@ const CreateTeamButton = forwardRef<HTMLDivElement, CreateTeamButtonProps>(({ se
})
CreateTeamButton.displayName = 'CreateTeamButton'
export default CreateTeamButton

View File

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

View File

@ -42,11 +42,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir
const fetchUsersNotInTeam = async () => {
try {
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()
setAllUsers(data.users || [])
} catch (err) {
console.error('Fehler beim Laden der Benutzer:', err)
} finally {
setIsLoading(false)
}

View File

@ -74,7 +74,7 @@ export function MatchDetails ({ match }: { match: Match }) {
<ColGroup />
<Table.Head>
<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 => (
<Table.Cell key={h} as="th">{h}</Table.Cell>
))}
@ -114,14 +114,19 @@ export function MatchDetails ({ match }: { match: Match }) {
</div>
</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?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k1 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k2 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k3 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k4 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.k5 ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.oneK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.twoK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.threeK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fourK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fiveK ?? '-'}</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>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>

View File

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

View File

@ -5,7 +5,7 @@ import NotificationDropdown from './NotificationDropdown'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useSSEStore } from '@/app/lib/useSSEStore'
import { NOTIFICATION_EVENTS } from '../lib/sseEvents'
import { NOTIFICATION_EVENTS, isSseEventType } from '../lib/sseEvents'
type Notification = {
id: string
@ -42,6 +42,36 @@ export default function NotificationCenter() {
const [showPreview, setShowPreview] = 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
useEffect(() => {
const steamId = session?.user?.steamId

View File

@ -1,213 +1,199 @@
'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 { useSession, signIn, signOut } from 'next-auth/react'
import { useRouter, usePathname } from 'next/navigation'
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 LoadingSpinner from '@/app/components/LoadingSpinner'
import Button from './Button'
import PremierRankBadge from './PremierRankBadge'
export default function SidebarFooter() {
const router = useRouter()
const { session, steamProfile, status } = useSteamProfile()
const [isOpen, setIsOpen] = useState(false)
const pathname = usePathname()
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(() => {
const loadTeamName = async () => {
const teamId = session?.user?.team
if (!teamId) {
setTeamName(null)
return
}
try {
const res = await fetch(`/api/team/${teamId}`)
const data = await res.json()
setTeamName(data?.teamname ?? null)
} catch (err) {
console.error('[SidebarFooter] TeamName konnte nicht geladen werden:', err)
setTeamName(null)
}
if (status !== 'authenticated') {
setTeamName(null)
setPremierRank(0) // ← immer 0, nicht null
return
}
(async () => {
try {
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return
const user = await res.json()
const rank = typeof user?.premierRank === 'number' ? user.premierRank : 0
setPremierRank(rank)
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') {
return (
<>
<button
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"
>
<i className="fab fa-steam" />
<span>Mit Steam anmelden</span>
</button>
</>
<button
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"
>
<i className="fab fa-steam" />
<span>Mit Steam anmelden</span>
</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 =
teamName // 1. Teamname, wenn vorhanden
?? steamProfile?.steamId // 2. SteamID (wenn bereits vom Hook gemappt)
?? user.id // 3. Fallback auf JWTid
const linkClass = (active: boolean) =>
`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors ${
active
? '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 (
<>
<div className="relative w-full">
{/* Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-full inline-flex items-center gap-x-2 px-4 py-3 text-sm text-left text-gray-800 transition-all duration-100
${isOpen ? 'bg-gray-100 dark:bg-neutral-700' : 'hover:bg-gray-100 dark:hover:bg-neutral-700'}
`}
>
<div className="shrink-0 group block">
<div className="flex items-center">
<Image
src={steamProfile?.avatarfull || user?.image || '/default-avatar.png'}
quality={75}
width={40}
height={40}
className="inline-block shrink-0 size-10 rounded-full"
draggable={false}
alt="Avatar"
/>
<div className="ms-3">
<h3 className="font-semibold text-gray-800 dark:text-white">{steamProfile?.personaname || user?.name}</h3>
<p className="text-xs font-medium text-gray-400 dark:text-neutral-500">{subline}</p>
</div>
</div>
<div className="relative w-full">
{/* Kopf / Toggle */}
<button
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
${isOpen ? 'bg-gray-100 dark:bg-neutral-700' : 'hover:bg-gray-100 dark:hover:bg-neutral-700'}
`}
>
{/* Linker Block: Avatar + Name/Subline + Rank */}
<div className="flex items-center flex-1 min-w-0">
<Image
src={avatarSrc}
quality={75}
width={40}
height={40}
className="inline-block size-10 rounded-full shrink-0"
draggable={false}
alt="Avatar"
/>
<div className="ms-3 flex-1 min-w-0">
<h3 className="font-semibold text-gray-800 dark:text-white truncate">
{userName}
</h3>
<p className="text-xs font-medium text-gray-400 dark:text-neutral-500 truncate">
{subline}
</p>
</div>
<svg
className={`ms-auto size-4 group-hover:text-gray-500 ${
isOpen ? 'rotate-180' : ''
} text-gray-600 dark:text-neutral-400`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{/* Badge darf nicht schrumpfen */}
<div className="ml-2 flex-shrink-0">
<PremierRankBadge rank={premierRank} />
</div>
</div>
{/* Pfeil ebenfalls nicht schrumpfen */}
<svg
className={`ms-2 size-4 shrink-0 ${isOpen ? 'rotate-180' : ''} text-gray-600 dark:text-neutral-400`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeWidth={2} d="m5 15 7-7 7 7" />
</svg>
</button>
{/* Dropdown */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden w-full bg-white shadow-lg dark:bg-neutral-800 dark:border-neutral-600 z-20"
>
<path strokeWidth={2} d="m5 15 7-7 7 7" />
</svg>
</button>
<div className="p-2 flex flex-col gap-1">
<Button
onClick={() => router.push(`/profile/${session?.user?.steamId}`)}
size="sm"
variant="link"
className={linkClass(pathname === `/profile/${session?.user?.steamId}`)}
>
<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"/>
</svg>
Profil
</Button>
{/* Menü */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden w-full bg-white shadow-lg dark:bg-neutral-800 dark:border-neutral-600 z-20"
>
<div className="p-2 flex flex-col gap-1">
<Button
onClick={() => router.push('/matches')}
size='sm'
variant='link'
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${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">
<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" />
</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"/>
</svg>
Profil
</Button>
<Button
onClick={() => router.push(`/team`)}
size='sm'
variant='link'
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${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" >
<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>
Team
</Button>
<Button
onClick={() => router.push('/settings')}
size='sm'
variant='link'
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${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" >
<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>
<Button
onClick={() => router.push(`/team`)}
size="sm"
variant="link"
className={linkClass(pathname === '/team')}
>
<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"/>
</svg>
Team
</Button>
Einstellungen
</Button>
{user?.isAdmin && (
<Button
onClick={() => router.push('/settings')}
size="sm"
variant="link"
className={linkClass(pathname.startsWith('/settings'))}
>
<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"/>
</svg>
Einstellungen
</Button>
{session?.user?.isAdmin && (
<Button
onClick={() => router.push('/admin')}
size='sm'
variant='link'
className={`flex items-center gap-x-3.5 py-2 px-2.5 text-sm rounded-lg transition-colors
${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'
}`}
size="sm"
variant="link"
className={linkClass(pathname.startsWith('/admin'))}
>
<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" >
<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 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"/>
</svg>
Administration
</Button>
)}
<Button
onClick={() => signOut({ callbackUrl: '/' })}
size='sm'
variant='link'
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`} >
<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="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/>
</svg>
Abmelden
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</>
<Button
onClick={() => signOut({ callbackUrl: '/' })}
size="sm"
variant="link"
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"
>
<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"/>
</svg>
Abmelden
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

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

View File

@ -64,7 +64,7 @@ export default function TeamInvitationView({
{/* Inhalt */}
<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">
<img
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
@ -101,7 +101,7 @@ export default function TeamInvitationView({
<Button
title="Ablehnen"
size="sm"
color="gray"
color="red"
variant="ghost"
disabled={isSubmitting !== null}
onClick={(e) => {
@ -154,21 +154,21 @@ export default function TeamInvitationView({
/* weicher, dezenter Verlauf */
background-image: repeating-linear-gradient(
90deg,
rgba(16,185,129,0.10) 0%,
rgba(16,185,129,0.06) 50%,
rgba(16,185,129,0.10) 100%
rgba(16, 168, 54, 0.20) 0%,
rgba(16, 168, 54, 0.04) 50%,
rgba(16, 168, 54, 0.20) 100%
);
background-size: 200% 100%;
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 {
background-image: repeating-linear-gradient(
90deg,
rgba(16,185,129,0.18) 0%,
rgba(16,185,129,0.08) 50%,
rgba(16,185,129,0.18) 100%
rgba(16, 168, 54, 0.28) 0%,
rgba(16, 168, 54, 0.08) 50%,
rgba(16, 168, 54, 0.28) 100%
);
}

View File

@ -20,6 +20,12 @@ import Link from 'next/link'
import { Team } from '../types/team'
import { useTeamStore } from '../lib/stores'
import { useSSEStore } from '@/app/lib/useSSEStore'
import {
TEAM_EVENTS,
SELF_EVENTS,
isSseEventType,
type SSEEventType,
} from '@/app/lib/sseEvents'
type Props = {
team?: Team
@ -55,6 +61,11 @@ export default function TeamMemberView({
const team = teamProp ?? storeTeam
if (!team) return null
const RELEVANT: ReadonlySet<SSEEventType> = new Set([
...TEAM_EVENTS,
...SELF_EVENTS,
])
const isLeader = currentUserSteamId === team.leader
const canManage = adminMode || isLeader
const canInvite = isLeader && !adminMode
@ -122,29 +133,18 @@ export default function TeamMemberView({
useEffect(() => {
if (!lastEvent || !team?.id) return
const RELEVANT = new Set([
'team-updated',
'team-leader-changed',
'team-member-joined',
'team-member-left',
'team-kick',
'team-kick-other',
'team-left',
'team-renamed',
'team-logo-updated',
])
// Typ-Safety: nur kanonische Events weiter verarbeiten
if (!isSseEventType(lastEvent.type)) return
const evtType: SSEEventType = lastEvent.type
if (!RELEVANT.has(lastEvent.type)) return
if (!RELEVANT.has(evtType)) return
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
;(async () => {
const updated = await reloadTeam(team.id)
if (!updated) return
setTeam(updated)
setEditedName(updated.name || '')
@ -216,16 +216,44 @@ export default function TeamMemberView({
isDraggingRef.current = false
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 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
// Woher kam die Karte?
const wasInActive = activePlayers.some(p => p.steamId === activeId)
// Wohin wurde gedroppt?
const dropToActive =
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 nextInactive = [...inactivePlayers]
@ -238,27 +266,39 @@ export default function TeamMemberView({
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))
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)
setInactivePlayers(nextInactive)
// 🔔 Server informieren (SSE triggert andere Clients)
// 🔔 Server informieren
updateTeamMembers(team.id, nextActive, nextInactive).catch(console.error)
setSaveSuccess(true)
setTimeout(()=>setSaveSuccess(false), 3000)
// 📨 Falls während des Drags ein Remote-Update kam → jetzt anwenden
// 📨 evtl. gepufferte Remote-Änderungen übernehmen
if (pendingRemote) {
// nur übernehmen, wenn abweichend (optional)
const diff =
!eqByIds(pendingRemote.active, nextActive) ||
!eqByIds(pendingRemote.inactive, nextInactive) ||
!eqByIds(pendingRemote.invited, invitedPlayers)
if (diff) {
setActivePlayers(pendingRemote.active)
setInactivePlayers(pendingRemote.inactive)
@ -460,16 +500,15 @@ export default function TeamMemberView({
<div className="flex gap-2">
{canManage && (
<button
type="button"
<Button
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
</button>
</Button>
)}
<button
type="button"
<Button
onClick={async () => {
if (isLeader) {
setShowLeaveModal(true)
@ -477,10 +516,11 @@ export default function TeamMemberView({
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
</button>
</Button>
</div>
</div>
@ -504,7 +544,7 @@ export default function TeamMemberView({
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">
<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>
@ -538,8 +578,31 @@ export default function TeamMemberView({
isSelectable={false}
isInvite={true}
rank={player.premierRank}
onKick={revokeInvitation}
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>
))}
@ -669,7 +732,7 @@ export default function TeamMemberView({
body: JSON.stringify({ teamId: team.id }),
})
setShowDeleteModal(false)
window.location.href = '/'
window.location.href = '/team'
}}
closeButtonTitle="Löschen"
closeButtonColor="red"

View File

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

View File

@ -24,6 +24,12 @@ interface Match {
rankNew : number
rankOld : number
rankChange : number | null
oneK : number
twoK : number
threeK : number
fourK : number
fiveK : number
aim : number
}
/* ───────── Hilfsfunktionen ───────── */
@ -51,7 +57,7 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
{/* Kopf */}
<Table.Head>
<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.Row>
@ -125,6 +131,11 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
</Table.Cell>
{/* Stats */}
<Table.Cell>
{Number.isFinite(Number(m.aim))
? `${Number(m.aim).toFixed(0)} %`
: '-'}
</Table.Cell>
<Table.Cell>{m.kills}</Table.Cell>
<Table.Cell>{m.deaths}</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
'team-updated',
'team-leader-changed',
'team-leader-self', // ⬅️ neu
'team-leader-self',
'team-renamed',
'team-logo-updated',
'team-member-joined',
@ -17,34 +17,20 @@ export const SSE_EVENT_TYPES = [
'invitation',
'team-invite',
'team-join-request',
'team-joined', // ⬅️ neu (eigene Bestätigung)
'team-joined',
'expired-sharecode',
// optional/robust, nur falls noch emittiert:
// 'team-invite-reject',
// 'team-join-request-reject',
'team-invite-revoked',
] as const;
export type SSEEventType = typeof SSE_EVENT_TYPES[number];
/** Legacy-Namen, die noch von alten Endpunkten kommen können */
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 */
/** Type Guard */
export function isSseEventType(x: unknown): x is SSEEventType {
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 */
export const TEAM_EVENTS = new Set<SSEEventType>([
export const TEAM_EVENTS: ReadonlySet<SSEEventType> = new Set([
'team-updated',
'team-leader-changed',
'team-renamed',
@ -54,52 +40,32 @@ export const TEAM_EVENTS = new Set<SSEEventType>([
'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-left-self',
'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)
export const NOTIFICATION_EVENTS = new Set([
// Event-Typen, die das NotificationCenter betreffen
export const NOTIFICATION_EVENTS: ReadonlySet<SSEEventType> = new Set([
'notification',
'invitation',
'team-invite',
'team-join-request',
'expired-sharecode',
// ⬇️ damit „… ist deinem Team beigetreten“ & Leader-Änderungen live erscheinen
'team-member-joined',
'team-joined',
'team-leader-changed',
'team-leader-self',
// optional/robust:
// 'team-invite-reject',
// 'team-join-request-reject',
'expired-sharecode',
]);
/** Legacy → Kanonisch normalisieren */
export function normalizeEventType(
incoming: string,
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;
}
/** Nur noch: akzeptiere kanonische Typen, sonst null */
export function normalizeEventType(incoming: string): SSEEventType | null {
return isSseEventType(incoming) ? incoming : null;
}

View File

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

View File

@ -1,3 +1,5 @@
import { MatchPlayer } from "@/generated/prisma"
// /types/team.ts
export type Player = {
steamId: string
@ -12,7 +14,6 @@ export type InvitedPlayer = Player & {
invitationId: string
}
export type Team = {
id: string
name?: string | null
@ -22,3 +23,11 @@ export type Team = {
inactivePlayers: Player[]
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',
noScopes: 'noScopes',
blindKills: 'blindKills',
k1: 'k1',
k2: 'k2',
k3: 'k3',
k4: 'k4',
k5: 'k5',
aim: 'aim',
oneK: 'oneK',
twoK: 'twoK',
threeK: 'threeK',
fourK: 'fourK',
fiveK: 'fiveK',
rankOld: 'rankOld',
rankNew: 'rankNew',
rankChange: 'rankChange',

View File

@ -9630,11 +9630,12 @@ export namespace Prisma {
headshots: number | null
noScopes: number | null
blindKills: number | null
k1: number | null
k2: number | null
k3: number | null
k4: number | null
k5: number | null
aim: number | null
oneK: number | null
twoK: number | null
threeK: number | null
fourK: number | null
fiveK: number | null
rankOld: number | null
rankNew: number | null
rankChange: number | null
@ -9660,11 +9661,12 @@ export namespace Prisma {
headshots: number | null
noScopes: number | null
blindKills: number | null
k1: number | null
k2: number | null
k3: number | null
k4: number | null
k5: number | null
aim: number | null
oneK: number | null
twoK: number | null
threeK: number | null
fourK: number | null
fiveK: number | null
rankOld: number | null
rankNew: number | null
rankChange: number | null
@ -9693,11 +9695,12 @@ export namespace Prisma {
headshots: number | null
noScopes: number | null
blindKills: number | null
k1: number | null
k2: number | null
k3: number | null
k4: number | null
k5: number | null
aim: number | null
oneK: number | null
twoK: number | null
threeK: number | null
fourK: number | null
fiveK: number | null
rankOld: number | null
rankNew: number | null
rankChange: number | null
@ -9726,11 +9729,12 @@ export namespace Prisma {
headshots: number | null
noScopes: number | null
blindKills: number | null
k1: number | null
k2: number | null
k3: number | null
k4: number | null
k5: number | null
aim: number | null
oneK: number | null
twoK: number | null
threeK: number | null
fourK: number | null
fiveK: number | null
rankOld: number | null
rankNew: number | null
rankChange: number | null
@ -9759,11 +9763,12 @@ export namespace Prisma {
headshots: number
noScopes: number
blindKills: number
k1: number
k2: number
k3: number
k4: number
k5: number
aim: number
oneK: number
twoK: number
threeK: number
fourK: number
fiveK: number
rankOld: number
rankNew: number
rankChange: number
@ -9791,11 +9796,12 @@ export namespace Prisma {
headshots?: true
noScopes?: true
blindKills?: true
k1?: true
k2?: true
k3?: true
k4?: true
k5?: true
aim?: true
oneK?: true
twoK?: true
threeK?: true
fourK?: true
fiveK?: true
rankOld?: true
rankNew?: true
rankChange?: true
@ -9821,11 +9827,12 @@ export namespace Prisma {
headshots?: true
noScopes?: true
blindKills?: true
k1?: true
k2?: true
k3?: true
k4?: true
k5?: true
aim?: true
oneK?: true
twoK?: true
threeK?: true
fourK?: true
fiveK?: true
rankOld?: true
rankNew?: true
rankChange?: true
@ -9854,11 +9861,12 @@ export namespace Prisma {
headshots?: true
noScopes?: true
blindKills?: true
k1?: true
k2?: true
k3?: true
k4?: true
k5?: true
aim?: true
oneK?: true
twoK?: true
threeK?: true
fourK?: true
fiveK?: true
rankOld?: true
rankNew?: true
rankChange?: true
@ -9887,11 +9895,12 @@ export namespace Prisma {
headshots?: true
noScopes?: true
blindKills?: true
k1?: true
k2?: true
k3?: true
k4?: true
k5?: true
aim?: true
oneK?: true
twoK?: true
threeK?: true
fourK?: true
fiveK?: true
rankOld?: true
rankNew?: true
rankChange?: true
@ -9920,11 +9929,12 @@ export namespace Prisma {
headshots?: true
noScopes?: true
blindKills?: true
k1?: true
k2?: true
k3?: true
k4?: true
k5?: true
aim?: true
oneK?: true
twoK?: true
threeK?: true
fourK?: true
fiveK?: true
rankOld?: true
rankNew?: true
rankChange?: true
@ -10040,11 +10050,12 @@ export namespace Prisma {
headshots: number
noScopes: number
blindKills: number
k1: number
k2: number
k3: number
k4: number
k5: number
aim: number
oneK: number
twoK: number
threeK: number
fourK: number
fiveK: number
rankOld: number | null
rankNew: number | null
rankChange: number | null
@ -10092,11 +10103,12 @@ export namespace Prisma {
headshots?: boolean
noScopes?: boolean
blindKills?: boolean
k1?: boolean
k2?: boolean
k3?: boolean
k4?: boolean
k5?: boolean
aim?: boolean
oneK?: boolean
twoK?: boolean
threeK?: boolean
fourK?: boolean
fiveK?: boolean
rankOld?: boolean
rankNew?: boolean
rankChange?: boolean
@ -10126,11 +10138,12 @@ export namespace Prisma {
headshots?: boolean
noScopes?: boolean
blindKills?: boolean
k1?: boolean
k2?: boolean
k3?: boolean
k4?: boolean
k5?: boolean
aim?: boolean
oneK?: boolean
twoK?: boolean
threeK?: boolean
fourK?: boolean
fiveK?: boolean
rankOld?: boolean
rankNew?: boolean
rankChange?: boolean
@ -10160,11 +10173,12 @@ export namespace Prisma {
headshots?: boolean
noScopes?: boolean
blindKills?: boolean
k1?: boolean
k2?: boolean
k3?: boolean
k4?: boolean
k5?: boolean
aim?: boolean
oneK?: boolean
twoK?: boolean
threeK?: boolean
fourK?: boolean
fiveK?: boolean
rankOld?: boolean
rankNew?: boolean
rankChange?: boolean
@ -10194,18 +10208,19 @@ export namespace Prisma {
headshots?: boolean
noScopes?: boolean
blindKills?: boolean
k1?: boolean
k2?: boolean
k3?: boolean
k4?: boolean
k5?: boolean
aim?: boolean
oneK?: boolean
twoK?: boolean
threeK?: boolean
fourK?: boolean
fiveK?: boolean
rankOld?: boolean
rankNew?: boolean
rankChange?: 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> = {
matchPlayer?: boolean | MatchPlayerDefaultArgs<ExtArgs>
}
@ -10243,11 +10258,12 @@ export namespace Prisma {
headshots: number
noScopes: number
blindKills: number
k1: number
k2: number
k3: number
k4: number
k5: number
aim: number
oneK: number
twoK: number
threeK: number
fourK: number
fiveK: number
rankOld: number | null
rankNew: number | null
rankChange: number | null
@ -10697,11 +10713,12 @@ export namespace Prisma {
readonly headshots: FieldRef<"PlayerStats", 'Int'>
readonly noScopes: FieldRef<"PlayerStats", 'Int'>
readonly blindKills: FieldRef<"PlayerStats", 'Int'>
readonly k1: FieldRef<"PlayerStats", 'Int'>
readonly k2: FieldRef<"PlayerStats", 'Int'>
readonly k3: FieldRef<"PlayerStats", 'Int'>
readonly k4: FieldRef<"PlayerStats", 'Int'>
readonly k5: FieldRef<"PlayerStats", 'Int'>
readonly aim: FieldRef<"PlayerStats", 'Int'>
readonly oneK: FieldRef<"PlayerStats", 'Int'>
readonly twoK: FieldRef<"PlayerStats", 'Int'>
readonly threeK: FieldRef<"PlayerStats", 'Int'>
readonly fourK: FieldRef<"PlayerStats", 'Int'>
readonly fiveK: FieldRef<"PlayerStats", 'Int'>
readonly rankOld: FieldRef<"PlayerStats", 'Int'>
readonly rankNew: FieldRef<"PlayerStats", 'Int'>
readonly rankChange: FieldRef<"PlayerStats", 'Int'>
@ -15913,11 +15930,12 @@ export namespace Prisma {
headshots: 'headshots',
noScopes: 'noScopes',
blindKills: 'blindKills',
k1: 'k1',
k2: 'k2',
k3: 'k3',
k4: 'k4',
k5: 'k5',
aim: 'aim',
oneK: 'oneK',
twoK: 'twoK',
threeK: 'threeK',
fourK: 'fourK',
fiveK: 'fiveK',
rankOld: 'rankOld',
rankNew: 'rankNew',
rankChange: 'rankChange',
@ -16708,11 +16726,12 @@ export namespace Prisma {
headshots?: IntFilter<"PlayerStats"> | number
noScopes?: IntFilter<"PlayerStats"> | number
blindKills?: IntFilter<"PlayerStats"> | number
k1?: IntFilter<"PlayerStats"> | number
k2?: IntFilter<"PlayerStats"> | number
k3?: IntFilter<"PlayerStats"> | number
k4?: IntFilter<"PlayerStats"> | number
k5?: IntFilter<"PlayerStats"> | number
aim?: IntFilter<"PlayerStats"> | number
oneK?: IntFilter<"PlayerStats"> | number
twoK?: IntFilter<"PlayerStats"> | number
threeK?: IntFilter<"PlayerStats"> | number
fourK?: IntFilter<"PlayerStats"> | number
fiveK?: IntFilter<"PlayerStats"> | number
rankOld?: IntNullableFilter<"PlayerStats"> | number | null
rankNew?: IntNullableFilter<"PlayerStats"> | number | null
rankChange?: IntNullableFilter<"PlayerStats"> | number | null
@ -16742,11 +16761,12 @@ export namespace Prisma {
headshots?: SortOrder
noScopes?: SortOrder
blindKills?: SortOrder
k1?: SortOrder
k2?: SortOrder
k3?: SortOrder
k4?: SortOrder
k5?: SortOrder
aim?: SortOrder
oneK?: SortOrder
twoK?: SortOrder
threeK?: SortOrder
fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrderInput | SortOrder
rankNew?: SortOrderInput | SortOrder
rankChange?: SortOrderInput | SortOrder
@ -16780,11 +16800,12 @@ export namespace Prisma {
headshots?: IntFilter<"PlayerStats"> | number
noScopes?: IntFilter<"PlayerStats"> | number
blindKills?: IntFilter<"PlayerStats"> | number
k1?: IntFilter<"PlayerStats"> | number
k2?: IntFilter<"PlayerStats"> | number
k3?: IntFilter<"PlayerStats"> | number
k4?: IntFilter<"PlayerStats"> | number
k5?: IntFilter<"PlayerStats"> | number
aim?: IntFilter<"PlayerStats"> | number
oneK?: IntFilter<"PlayerStats"> | number
twoK?: IntFilter<"PlayerStats"> | number
threeK?: IntFilter<"PlayerStats"> | number
fourK?: IntFilter<"PlayerStats"> | number
fiveK?: IntFilter<"PlayerStats"> | number
rankOld?: IntNullableFilter<"PlayerStats"> | number | null
rankNew?: IntNullableFilter<"PlayerStats"> | number | null
rankChange?: IntNullableFilter<"PlayerStats"> | number | null
@ -16814,11 +16835,12 @@ export namespace Prisma {
headshots?: SortOrder
noScopes?: SortOrder
blindKills?: SortOrder
k1?: SortOrder
k2?: SortOrder
k3?: SortOrder
k4?: SortOrder
k5?: SortOrder
aim?: SortOrder
oneK?: SortOrder
twoK?: SortOrder
threeK?: SortOrder
fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrderInput | SortOrder
rankNew?: SortOrderInput | SortOrder
rankChange?: SortOrderInput | SortOrder
@ -16855,11 +16877,12 @@ export namespace Prisma {
headshots?: IntWithAggregatesFilter<"PlayerStats"> | number
noScopes?: IntWithAggregatesFilter<"PlayerStats"> | number
blindKills?: IntWithAggregatesFilter<"PlayerStats"> | number
k1?: IntWithAggregatesFilter<"PlayerStats"> | number
k2?: IntWithAggregatesFilter<"PlayerStats"> | number
k3?: IntWithAggregatesFilter<"PlayerStats"> | number
k4?: IntWithAggregatesFilter<"PlayerStats"> | number
k5?: IntWithAggregatesFilter<"PlayerStats"> | number
aim?: IntWithAggregatesFilter<"PlayerStats"> | number
oneK?: IntWithAggregatesFilter<"PlayerStats"> | number
twoK?: IntWithAggregatesFilter<"PlayerStats"> | number
threeK?: IntWithAggregatesFilter<"PlayerStats"> | number
fourK?: IntWithAggregatesFilter<"PlayerStats"> | number
fiveK?: IntWithAggregatesFilter<"PlayerStats"> | number
rankOld?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null
rankNew?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null
rankChange?: IntNullableWithAggregatesFilter<"PlayerStats"> | number | null
@ -17803,11 +17826,12 @@ export namespace Prisma {
headshots?: number
noScopes?: number
blindKills?: number
k1?: number
k2?: number
k3?: number
k4?: number
k5?: number
aim?: number
oneK?: number
twoK?: number
threeK?: number
fourK?: number
fiveK?: number
rankOld?: number | null
rankNew?: number | null
rankChange?: number | null
@ -17837,11 +17861,12 @@ export namespace Prisma {
headshots?: number
noScopes?: number
blindKills?: number
k1?: number
k2?: number
k3?: number
k4?: number
k5?: number
aim?: number
oneK?: number
twoK?: number
threeK?: number
fourK?: number
fiveK?: number
rankOld?: number | null
rankNew?: number | null
rankChange?: number | null
@ -17868,11 +17893,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number
aim?: IntFieldUpdateOperationsInput | number
oneK?: IntFieldUpdateOperationsInput | number
twoK?: IntFieldUpdateOperationsInput | number
threeK?: IntFieldUpdateOperationsInput | number
fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -17902,11 +17928,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number
aim?: IntFieldUpdateOperationsInput | number
oneK?: IntFieldUpdateOperationsInput | number
twoK?: IntFieldUpdateOperationsInput | number
threeK?: IntFieldUpdateOperationsInput | number
fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -17935,11 +17962,12 @@ export namespace Prisma {
headshots?: number
noScopes?: number
blindKills?: number
k1?: number
k2?: number
k3?: number
k4?: number
k5?: number
aim?: number
oneK?: number
twoK?: number
threeK?: number
fourK?: number
fiveK?: number
rankOld?: number | null
rankNew?: number | null
rankChange?: number | null
@ -17966,11 +17994,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number
aim?: IntFieldUpdateOperationsInput | number
oneK?: IntFieldUpdateOperationsInput | number
twoK?: IntFieldUpdateOperationsInput | number
threeK?: IntFieldUpdateOperationsInput | number
fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -17999,11 +18028,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number
aim?: IntFieldUpdateOperationsInput | number
oneK?: IntFieldUpdateOperationsInput | number
twoK?: IntFieldUpdateOperationsInput | number
threeK?: IntFieldUpdateOperationsInput | number
fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -18971,11 +19001,12 @@ export namespace Prisma {
headshots?: SortOrder
noScopes?: SortOrder
blindKills?: SortOrder
k1?: SortOrder
k2?: SortOrder
k3?: SortOrder
k4?: SortOrder
k5?: SortOrder
aim?: SortOrder
oneK?: SortOrder
twoK?: SortOrder
threeK?: SortOrder
fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder
rankNew?: SortOrder
rankChange?: SortOrder
@ -19001,11 +19032,12 @@ export namespace Prisma {
headshots?: SortOrder
noScopes?: SortOrder
blindKills?: SortOrder
k1?: SortOrder
k2?: SortOrder
k3?: SortOrder
k4?: SortOrder
k5?: SortOrder
aim?: SortOrder
oneK?: SortOrder
twoK?: SortOrder
threeK?: SortOrder
fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder
rankNew?: SortOrder
rankChange?: SortOrder
@ -19034,11 +19066,12 @@ export namespace Prisma {
headshots?: SortOrder
noScopes?: SortOrder
blindKills?: SortOrder
k1?: SortOrder
k2?: SortOrder
k3?: SortOrder
k4?: SortOrder
k5?: SortOrder
aim?: SortOrder
oneK?: SortOrder
twoK?: SortOrder
threeK?: SortOrder
fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder
rankNew?: SortOrder
rankChange?: SortOrder
@ -19067,11 +19100,12 @@ export namespace Prisma {
headshots?: SortOrder
noScopes?: SortOrder
blindKills?: SortOrder
k1?: SortOrder
k2?: SortOrder
k3?: SortOrder
k4?: SortOrder
k5?: SortOrder
aim?: SortOrder
oneK?: SortOrder
twoK?: SortOrder
threeK?: SortOrder
fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder
rankNew?: SortOrder
rankChange?: SortOrder
@ -19097,11 +19131,12 @@ export namespace Prisma {
headshots?: SortOrder
noScopes?: SortOrder
blindKills?: SortOrder
k1?: SortOrder
k2?: SortOrder
k3?: SortOrder
k4?: SortOrder
k5?: SortOrder
aim?: SortOrder
oneK?: SortOrder
twoK?: SortOrder
threeK?: SortOrder
fourK?: SortOrder
fiveK?: SortOrder
rankOld?: SortOrder
rankNew?: SortOrder
rankChange?: SortOrder
@ -23360,11 +23395,12 @@ export namespace Prisma {
headshots?: number
noScopes?: number
blindKills?: number
k1?: number
k2?: number
k3?: number
k4?: number
k5?: number
aim?: number
oneK?: number
twoK?: number
threeK?: number
fourK?: number
fiveK?: number
rankOld?: number | null
rankNew?: number | null
rankChange?: number | null
@ -23391,11 +23427,12 @@ export namespace Prisma {
headshots?: number
noScopes?: number
blindKills?: number
k1?: number
k2?: number
k3?: number
k4?: number
k5?: number
aim?: number
oneK?: number
twoK?: number
threeK?: number
fourK?: number
fiveK?: number
rankOld?: number | null
rankNew?: number | null
rankChange?: number | null
@ -23601,11 +23638,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number
aim?: IntFieldUpdateOperationsInput | number
oneK?: IntFieldUpdateOperationsInput | number
twoK?: IntFieldUpdateOperationsInput | number
threeK?: IntFieldUpdateOperationsInput | number
fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: NullableIntFieldUpdateOperationsInput | number | null
rankChange?: NullableIntFieldUpdateOperationsInput | number | null
@ -23632,11 +23670,12 @@ export namespace Prisma {
headshots?: IntFieldUpdateOperationsInput | number
noScopes?: IntFieldUpdateOperationsInput | number
blindKills?: IntFieldUpdateOperationsInput | number
k1?: IntFieldUpdateOperationsInput | number
k2?: IntFieldUpdateOperationsInput | number
k3?: IntFieldUpdateOperationsInput | number
k4?: IntFieldUpdateOperationsInput | number
k5?: IntFieldUpdateOperationsInput | number
aim?: IntFieldUpdateOperationsInput | number
oneK?: IntFieldUpdateOperationsInput | number
twoK?: IntFieldUpdateOperationsInput | number
threeK?: IntFieldUpdateOperationsInput | number
fourK?: IntFieldUpdateOperationsInput | number
fiveK?: IntFieldUpdateOperationsInput | number
rankOld?: NullableIntFieldUpdateOperationsInput | number | null
rankNew?: 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",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

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

View File

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

View File

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

View File

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