updated
This commit is contained in:
parent
55a12c1f68
commit
af9fe48584
@ -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 |
@ -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 })
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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“
|
||||
|
||||
@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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] Team‑Name 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. Steam‑ID (wenn bereits vom Hook gemappt)
|
||||
?? user.id // 3. Fallback auf JWT‑id
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -57,8 +57,6 @@ export default function MatchesAdminManager() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card maxWidth='auto'>
|
||||
<CommunityMatchList matchType="community" />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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',
|
||||
|
||||
431
src/generated/prisma/index.d.ts
vendored
431
src/generated/prisma/index.d.ts
vendored
@ -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
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-42c2f4122e5c92abceced92f5ccc724f4e2068f151760c3339b2243eb9d75d8e",
|
||||
"name": "prisma-client-606d0d92f2bc1947c35b0ceba01327a22f26543cd49f6fab394887ba3b7b7804",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user