updated
This commit is contained in:
parent
61c75b1c8c
commit
6caf57d282
@ -1,230 +0,0 @@
|
|||||||
// /app/api/matches/[id]/route.ts
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
|
||||||
import { prisma } from '@/app/lib/prisma'
|
|
||||||
import { getServerSession } from 'next-auth'
|
|
||||||
import { authOptions } from '@/app/lib/auth'
|
|
||||||
|
|
||||||
export async function GET(_: Request, context: { params: { id: string } }) {
|
|
||||||
const { id } = context.params
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const match = await prisma.match.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
players: {
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
stats: true,
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
teamAUsers: {
|
|
||||||
include: {
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
teamBUsers: {
|
|
||||||
include: {
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamAIds = new Set(match.teamAUsers.map(u => u.steamId));
|
|
||||||
const teamBIds = new Set(match.teamBUsers.map(u => u.steamId));
|
|
||||||
|
|
||||||
const playersA = match.players
|
|
||||||
.filter(p => teamAIds.has(p.steamId))
|
|
||||||
.map(p => ({
|
|
||||||
user: p.user,
|
|
||||||
stats: p.stats,
|
|
||||||
team: p.team?.name ?? 'Team A',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const playersB = match.players
|
|
||||||
.filter(p => teamBIds.has(p.steamId))
|
|
||||||
.map(p => ({
|
|
||||||
user: p.user,
|
|
||||||
stats: p.stats,
|
|
||||||
team: p.team?.name ?? 'Team B',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const teamA = {
|
|
||||||
name: match.teamAUsers[0]?.team?.name ?? 'Team A',
|
|
||||||
logo: null,
|
|
||||||
score: match.scoreA,
|
|
||||||
players: playersA,
|
|
||||||
};
|
|
||||||
|
|
||||||
const teamB = {
|
|
||||||
name: match.teamBUsers[0]?.team?.name ?? 'Team B',
|
|
||||||
logo: null,
|
|
||||||
score: match.scoreB,
|
|
||||||
players: playersB,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id: match.id,
|
|
||||||
title: match.title,
|
|
||||||
description: match.description,
|
|
||||||
demoDate: match.demoDate,
|
|
||||||
matchType: match.matchType,
|
|
||||||
roundCount: match.roundCount,
|
|
||||||
map: match.map,
|
|
||||||
teamA,
|
|
||||||
teamB,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`GET /matches/${id} failed:`, err)
|
|
||||||
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(req: NextRequest, context: { params: { id: string } }) {
|
|
||||||
const { id } = context.params
|
|
||||||
const session = await getServerSession(authOptions(req))
|
|
||||||
const userId = session?.user?.steamId
|
|
||||||
const isAdmin = session?.user?.isAdmin
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json()
|
|
||||||
const { title, description, matchDate, players } = body
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { steamId: userId },
|
|
||||||
include: { ledTeam: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const match = await prisma.match.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTeamLeaderA = match.teamAId && user?.ledTeam?.id === match.teamAId;
|
|
||||||
const isTeamLeaderB = match.teamBId && user?.ledTeam?.id === match.teamBId;
|
|
||||||
|
|
||||||
if (!isAdmin && !isTeamLeaderA && !isTeamLeaderB) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🛡️ Validierung: Nur eigene Spieler
|
|
||||||
if (!isAdmin) {
|
|
||||||
const ownTeamId = isTeamLeaderA ? match.teamAId : match.teamBId
|
|
||||||
|
|
||||||
if (!ownTeamId) {
|
|
||||||
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownTeam = await prisma.team.findUnique({ where: { id: ownTeamId } })
|
|
||||||
const allowed = new Set(ownTeam?.activePlayers || [])
|
|
||||||
|
|
||||||
const invalid = players.some((p: any) =>
|
|
||||||
p.teamId === ownTeamId && !allowed.has(p.userId)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (invalid) {
|
|
||||||
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ❌ Alte Spieler löschen
|
|
||||||
await prisma.matchPlayer.deleteMany({ where: { matchId: id } }) // ✅ Richtig, nur wenn das Feld korrekt heißt
|
|
||||||
|
|
||||||
// ✅ Neue Spieler speichern
|
|
||||||
await prisma.matchPlayer.createMany({
|
|
||||||
data: players.map((p: any) => ({
|
|
||||||
matchId: id,
|
|
||||||
userId: p.userId,
|
|
||||||
teamId: p.teamId,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
// ✏️ Match aktualisieren
|
|
||||||
const updated = await prisma.match.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
players: {
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
stats: true,
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔄 Spieler wieder trennen
|
|
||||||
const playersA = updated.players
|
|
||||||
.filter(p => p.teamId === updated.teamAId)
|
|
||||||
.map(p => ({
|
|
||||||
user: p.user,
|
|
||||||
stats: p.stats,
|
|
||||||
team: p.team?.name ?? 'CT',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const playersB = updated.players
|
|
||||||
.filter(p => p.teamId === updated.teamBId)
|
|
||||||
.map(p => ({
|
|
||||||
user: p.user,
|
|
||||||
stats: p.stats,
|
|
||||||
team: p.team?.name ?? 'T',
|
|
||||||
}))
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id: updated.id,
|
|
||||||
title: updated.title,
|
|
||||||
description: updated.description,
|
|
||||||
demoDate: updated.demoDate,
|
|
||||||
matchType: updated.matchType,
|
|
||||||
map: updated.map,
|
|
||||||
scoreA: updated.scoreA,
|
|
||||||
scoreB: updated.scoreB,
|
|
||||||
teamA: playersA,
|
|
||||||
teamB: playersB,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`PUT /matches/${id} failed:`, err)
|
|
||||||
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest, context: { params: { id: string } }) {
|
|
||||||
const { id } = context.params
|
|
||||||
const session = await getServerSession(authOptions(req))
|
|
||||||
|
|
||||||
if (!session?.user?.isAdmin) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Lösche Match inklusive aller zugehörigen MatchPlayer-Einträge (wenn onDelete: Cascade nicht aktiv)
|
|
||||||
await prisma.matchPlayer.deleteMany({ where: { matchId: id } })
|
|
||||||
|
|
||||||
// Lösche das Match
|
|
||||||
await prisma.match.delete({ where: { id } })
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`DELETE /matches/${id} failed:`, err)
|
|
||||||
return NextResponse.json({ error: 'Failed to delete match' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
149
src/app/api/matches/[matchId]/_builders.ts
Normal file
149
src/app/api/matches/[matchId]/_builders.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// /app/api/matches/[id]/_builders.ts
|
||||||
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
|
||||||
|
/** Klein, konsistent, Frontend-freundlich */
|
||||||
|
export function toPlayerVM(u: any) {
|
||||||
|
return {
|
||||||
|
steamId : u.steamId,
|
||||||
|
name : u.name ?? 'Unbekannt',
|
||||||
|
avatar : u.avatar ?? '/assets/img/avatars/default.png',
|
||||||
|
location : u.location ?? null,
|
||||||
|
premierRank: u.premierRank ?? null,
|
||||||
|
isAdmin : u.isAdmin ?? false,
|
||||||
|
createdAt : u.createdAt ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapMP = (p: any) => ({
|
||||||
|
user : toPlayerVM(p.user),
|
||||||
|
stats: p.stats ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Startzeit (matchDate || demoDate || createdAt) als Timestamp */
|
||||||
|
export function computeStartTs(m: any) {
|
||||||
|
const base = m.matchDate ?? m.demoDate ?? m.createdAt
|
||||||
|
return new Date(base).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFuture(m: any) {
|
||||||
|
return computeStartTs(m) > Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMMUNITY + ZUKUNFT:
|
||||||
|
* – zeige den ganzen Roster aus teamAUsers / teamBUsers (ohne Stats),
|
||||||
|
* damit man die Aufstellung bearbeiten kann.
|
||||||
|
*/
|
||||||
|
export async function buildCommunityFuturePayload(m: any) {
|
||||||
|
const aIds = Array.from(new Set(m.teamAUsers.map((u: any) => u.steamId)))
|
||||||
|
const bIds = Array.from(new Set(m.teamBUsers.map((u: any) => u.steamId)))
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { steamId: { in: [...aIds, ...bIds] } },
|
||||||
|
})
|
||||||
|
const byId = new Map(users.map(u => [u.steamId, toPlayerVM(u)]))
|
||||||
|
|
||||||
|
const teamAPlayers = aIds
|
||||||
|
.map(id => byId.get(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(u => ({ user: u, stats: null }))
|
||||||
|
.sort((a: any, b: any) => (a.user.name || '').localeCompare(b.user.name || ''))
|
||||||
|
|
||||||
|
const teamBPlayers = bIds
|
||||||
|
.map(id => byId.get(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(u => ({ user: u, stats: null }))
|
||||||
|
.sort((a: any, b: any) => (a.user.name || '').localeCompare(b.user.name || ''))
|
||||||
|
|
||||||
|
const startTs = computeStartTs(m)
|
||||||
|
const editableUntil = startTs - 60 * 60 * 1000 // 1h vor Start/Veto
|
||||||
|
|
||||||
|
return {
|
||||||
|
id : m.id,
|
||||||
|
title : m.title,
|
||||||
|
description: m.description ?? null,
|
||||||
|
matchType : m.matchType,
|
||||||
|
map : m.map ?? null,
|
||||||
|
roundCount: m.roundCount ?? null,
|
||||||
|
scoreA : m.scoreA ?? null,
|
||||||
|
scoreB : m.scoreB ?? null,
|
||||||
|
demoDate : m.demoDate ?? null,
|
||||||
|
matchDate : (m.matchDate ?? m.demoDate ?? m.createdAt)?.toISOString?.() ?? null,
|
||||||
|
editableUntil,
|
||||||
|
|
||||||
|
teamA: {
|
||||||
|
id : m.teamA?.id ?? null,
|
||||||
|
name : m.teamA?.name ?? m.teamAUsers[0]?.team?.name ?? 'Team A',
|
||||||
|
logo : m.teamA?.logo ?? m.teamAUsers[0]?.team?.logo ?? null,
|
||||||
|
score : m.scoreA ?? null,
|
||||||
|
leader: m.teamA?.leader ? toPlayerVM(m.teamA.leader) : undefined,
|
||||||
|
players: teamAPlayers,
|
||||||
|
},
|
||||||
|
teamB: {
|
||||||
|
id : m.teamB?.id ?? null,
|
||||||
|
name : m.teamB?.name ?? m.teamBUsers[0]?.team?.name ?? 'Team B',
|
||||||
|
logo : m.teamB?.logo ?? m.teamBUsers[0]?.team?.logo ?? null,
|
||||||
|
score : m.scoreB ?? null,
|
||||||
|
leader: m.teamB?.leader ? toPlayerVM(m.teamB.leader) : undefined,
|
||||||
|
players: teamBPlayers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEFAULT (premier/competitive oder beendetes community match):
|
||||||
|
* – zeige nur die Spieler, die dem Match zugeordnet sind (über teamAUsers/BUsers gematcht).
|
||||||
|
* – Stats bleiben erhalten (kommen aus m.players.include.stats).
|
||||||
|
*/
|
||||||
|
export function buildDefaultPayload(m: any) {
|
||||||
|
const teamAIds = new Set(m.teamAUsers.map((u: any) => u.steamId))
|
||||||
|
const teamBIds = new Set(m.teamBUsers.map((u: any) => u.steamId))
|
||||||
|
|
||||||
|
const playersA = (m.players ?? [])
|
||||||
|
.filter((p: any) => teamAIds.has(p.steamId))
|
||||||
|
.map((p: any) => ({
|
||||||
|
user: toPlayerVM(p.user),
|
||||||
|
stats: p.stats ?? null,
|
||||||
|
team: p.team?.name ?? 'Team A',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const playersB = (m.players ?? [])
|
||||||
|
.filter((p: any) => teamBIds.has(p.steamId))
|
||||||
|
.map((p: any) => ({
|
||||||
|
user: toPlayerVM(p.user),
|
||||||
|
stats: p.stats ?? null,
|
||||||
|
team: p.team?.name ?? 'Team B',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const matchDate = m.demoDate ?? m.matchDate ?? m.createdAt
|
||||||
|
|
||||||
|
return {
|
||||||
|
id : m.id,
|
||||||
|
title : m.title,
|
||||||
|
description: m.description ?? null,
|
||||||
|
matchType : m.matchType,
|
||||||
|
map : m.map ?? null,
|
||||||
|
roundCount: m.roundCount ?? null,
|
||||||
|
scoreA : m.scoreA ?? null,
|
||||||
|
scoreB : m.scoreB ?? null,
|
||||||
|
demoDate : m.demoDate ?? null,
|
||||||
|
matchDate : matchDate?.toISOString?.() ?? null,
|
||||||
|
|
||||||
|
teamA: {
|
||||||
|
id : m.teamA?.id ?? null,
|
||||||
|
name : m.teamA?.name ?? m.teamAUsers[0]?.team?.name ?? 'Team A',
|
||||||
|
logo : m.teamA?.logo ?? m.teamAUsers[0]?.team?.logo ?? null,
|
||||||
|
score : m.scoreA ?? null,
|
||||||
|
leader: m.teamA?.leader ? toPlayerVM(m.teamA.leader) : undefined,
|
||||||
|
players: playersA,
|
||||||
|
},
|
||||||
|
teamB: {
|
||||||
|
id : m.teamB?.id ?? null,
|
||||||
|
name : m.teamB?.name ?? m.teamBUsers[0]?.team?.name ?? 'Team B',
|
||||||
|
logo : m.teamB?.logo ?? m.teamBUsers[0]?.team?.logo ?? null,
|
||||||
|
score : m.scoreB ?? null,
|
||||||
|
leader: m.teamB?.leader ? toPlayerVM(m.teamB.leader) : undefined,
|
||||||
|
players: playersB,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,10 @@
|
|||||||
// /app/api/matches/[id]/delete/route.ts
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/app/lib/auth'
|
import { authOptions } from '@/app/lib/auth'
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions(req))
|
const session = await getServerSession(authOptions(req))
|
||||||
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
|
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
|
||||||
@ -13,7 +12,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
|||||||
return NextResponse.json({ message: 'Nur Admins dürfen löschen.' }, { status: 403 })
|
return NextResponse.json({ message: 'Nur Admins dürfen löschen.' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchId = params?.id
|
const matchId = params?.matchId
|
||||||
if (!matchId) {
|
if (!matchId) {
|
||||||
return NextResponse.json({ message: 'Match-ID fehlt.' }, { status: 400 })
|
return NextResponse.json({ message: 'Match-ID fehlt.' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@ -26,7 +25,6 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
|||||||
return NextResponse.json({ message: 'Match nicht gefunden.' }, { status: 404 })
|
return NextResponse.json({ message: 'Match nicht gefunden.' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alles in einer Transaktion löschen
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.mapVetoStep.deleteMany({ where: { veto: { matchId } } })
|
await tx.mapVetoStep.deleteMany({ where: { veto: { matchId } } })
|
||||||
await tx.mapVeto.deleteMany({ where: { matchId } })
|
await tx.mapVeto.deleteMany({ where: { matchId } })
|
||||||
@ -39,12 +37,10 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
|||||||
await tx.match.delete({ where: { id: matchId } })
|
await tx.match.delete({ where: { id: matchId } })
|
||||||
})
|
})
|
||||||
|
|
||||||
// 🔔 Realtime-Broadcasts (flat payload)
|
|
||||||
try {
|
try {
|
||||||
await sendServerSSEMessage({ type: 'match-deleted', matchId })
|
await sendServerSSEMessage({ type: 'match-deleted', matchId })
|
||||||
await sendServerSSEMessage({ type: 'matches-updated' })
|
await sendServerSSEMessage({ type: 'matches-updated' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Broadcast-Fehler sollen das Löschen nicht rückgängig machen
|
|
||||||
console.error('[DELETE MATCH] SSE broadcast failed', e)
|
console.error('[DELETE MATCH] SSE broadcast failed', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
104
src/app/api/matches/[matchId]/mapvote/reset/route.ts
Normal file
104
src/app/api/matches/[matchId]/mapvote/reset/route.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// /app/api/matches/[matchId]/mapvote/reset/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/app/lib/auth'
|
||||||
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
import { MapVetoAction } from '@/generated/prisma'
|
||||||
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
|
/** gleicher Pool wie in deiner mapvote-Route */
|
||||||
|
const ACTIVE_DUTY: string[] = [
|
||||||
|
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** identische Logik wie in deiner mapvote-Route */
|
||||||
|
function vetoOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
|
||||||
|
const base = match.matchDate ?? match.demoDate ?? new Date()
|
||||||
|
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
|
||||||
|
if (bestOf === 3) {
|
||||||
|
return [
|
||||||
|
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
|
||||||
|
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
|
||||||
|
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
|
||||||
|
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
|
||||||
|
{ order: 4, action: MapVetoAction.BAN, teamId: teamAId },
|
||||||
|
{ order: 5, action: MapVetoAction.BAN, teamId: teamBId },
|
||||||
|
{ order: 6, action: MapVetoAction.DECIDER, teamId: null },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
// BO5
|
||||||
|
return [
|
||||||
|
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
|
||||||
|
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
|
||||||
|
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
|
||||||
|
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
|
||||||
|
{ order: 4, action: MapVetoAction.PICK, teamId: teamAId },
|
||||||
|
{ order: 5, action: MapVetoAction.PICK, teamId: teamBId },
|
||||||
|
{ order: 6, action: MapVetoAction.PICK, teamId: teamAId },
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
if (!session?.user?.isAdmin) {
|
||||||
|
return NextResponse.json({ message: 'Nicht autorisiert' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchId = params.matchId
|
||||||
|
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
|
||||||
|
|
||||||
|
// Match laden (inkl. Teams & BestOf für Steps)
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id: matchId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bestOf: true,
|
||||||
|
matchDate: true,
|
||||||
|
demoDate: true,
|
||||||
|
teamA: { select: { id: true } },
|
||||||
|
teamB: { select: { id: true } },
|
||||||
|
mapVeto: { select: { id: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!match || !match.teamA?.id || !match.teamB?.id) {
|
||||||
|
return NextResponse.json({ message: 'Match/Teams nicht gefunden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestOf = match.bestOf ?? 3
|
||||||
|
const stepsDef = buildSteps(bestOf, match.teamA.id, match.teamB.id)
|
||||||
|
const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
|
||||||
|
|
||||||
|
// Reset in einer TX: alte Steps -> löschen, MapVeto -> löschen, neu anlegen
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (match.mapVeto?.id) {
|
||||||
|
await tx.mapVetoStep.deleteMany({ where: { vetoId: match.mapVeto.id } })
|
||||||
|
await tx.mapVeto.delete({ where: { matchId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.mapVeto.create({
|
||||||
|
data: {
|
||||||
|
matchId,
|
||||||
|
bestOf,
|
||||||
|
mapPool: ACTIVE_DUTY,
|
||||||
|
currentIdx: 0,
|
||||||
|
locked: false,
|
||||||
|
opensAt,
|
||||||
|
steps: {
|
||||||
|
create: stepsDef.map(s => ({
|
||||||
|
order: s.order,
|
||||||
|
action: s.action,
|
||||||
|
teamId: s.teamId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🔔 UI-Refresh für alle Clients
|
||||||
|
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true })
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
// /app/api/matches/[id]/map-vote/route.ts
|
// /app/api/matches/[id]/mapvote/route.ts
|
||||||
import { NextResponse, NextRequest } from 'next/server'
|
import { NextResponse, NextRequest } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/app/lib/auth'
|
import { authOptions } from '@/app/lib/auth'
|
||||||
@ -260,9 +260,9 @@ async function buildTeamsPayload(match: any, req: NextRequest) {
|
|||||||
|
|
||||||
/* -------------------- GET -------------------- */
|
/* -------------------- GET -------------------- */
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function GET(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||||
try {
|
try {
|
||||||
const matchId = params.id
|
const matchId = params.matchId
|
||||||
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
|
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
|
||||||
|
|
||||||
const { match, veto } = await ensureVeto(matchId)
|
const { match, veto } = await ensureVeto(matchId)
|
||||||
@ -282,12 +282,12 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||||||
|
|
||||||
/* -------------------- POST ------------------- */
|
/* -------------------- POST ------------------- */
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||||
const session = await getServerSession(authOptions(req))
|
const session = await getServerSession(authOptions(req))
|
||||||
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
|
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
|
||||||
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||||
|
|
||||||
const matchId = params.id
|
const matchId = params.matchId
|
||||||
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
|
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
|
||||||
|
|
||||||
let body: { map?: string } = {}
|
let body: { map?: string } = {}
|
||||||
113
src/app/api/matches/[matchId]/meta/route.ts
Normal file
113
src/app/api/matches/[matchId]/meta/route.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// /app/api/matches/[id]/meta/route.ts
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/app/lib/auth'
|
||||||
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||||
|
const id = params.matchId
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const me = session?.user
|
||||||
|
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const { title, matchType, teamAId, teamBId, matchDate, map, vetoLeadMinutes } = body ?? {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
teamA: { include: { leader: true } },
|
||||||
|
teamB: { include: { leader: true } },
|
||||||
|
mapVeto: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const isAdmin = !!me.isAdmin
|
||||||
|
const isLeaderA = !!match.teamAId && match.teamA?.leader?.steamId === me.steamId
|
||||||
|
const isLeaderB = !!match.teamBId && match.teamB?.leader?.steamId === me.steamId
|
||||||
|
if (!isAdmin && !isLeaderA && !isLeaderB) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {}
|
||||||
|
if (typeof title !== 'undefined') updateData.title = title
|
||||||
|
if (typeof matchType === 'string') updateData.matchType = matchType
|
||||||
|
if (typeof map !== 'undefined') updateData.map = map
|
||||||
|
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
|
||||||
|
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
|
||||||
|
if (typeof matchDate === 'string' || matchDate === null) {
|
||||||
|
updateData.matchDate = matchDate ? new Date(matchDate) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = Number.isFinite(Number(vetoLeadMinutes)) ? Number(vetoLeadMinutes) : 60
|
||||||
|
let opensAt: Date | null = null
|
||||||
|
if (updateData.matchDate instanceof Date) {
|
||||||
|
opensAt = new Date(updateData.matchDate.getTime() - lead * 60 * 1000)
|
||||||
|
} else if (match.matchDate) {
|
||||||
|
opensAt = new Date(match.matchDate.getTime() - lead * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
const m = await tx.match.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: { mapVeto: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (opensAt) {
|
||||||
|
if (!m.mapVeto) {
|
||||||
|
await tx.mapVeto.create({
|
||||||
|
data: {
|
||||||
|
matchId: m.id,
|
||||||
|
opensAt,
|
||||||
|
// entferne Felder, die es im Schema nicht gibt (z. B. isOpen)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await tx.mapVeto.update({
|
||||||
|
where: { id: m.mapVeto.id },
|
||||||
|
data: { opensAt },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.match.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
teamA: { include: { leader: true } },
|
||||||
|
teamB: { include: { leader: true } },
|
||||||
|
mapVeto: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
|
||||||
|
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'match-updated',
|
||||||
|
payload: { matchId: updated.id, updatedAt: new Date().toISOString() },
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'map-vote-updated',
|
||||||
|
payload: { matchId: updated.id, opensAt: updated.mapVeto?.opensAt ?? null },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: updated.id,
|
||||||
|
title: updated.title,
|
||||||
|
matchType: updated.matchType,
|
||||||
|
teamAId: updated.teamAId,
|
||||||
|
teamBId: updated.teamBId,
|
||||||
|
matchDate: updated.matchDate,
|
||||||
|
map: updated.map,
|
||||||
|
mapVeto: updated.mapVeto,
|
||||||
|
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`PUT /matches/${id}/meta failed:`, err)
|
||||||
|
return NextResponse.json({ error: 'Failed to update match meta' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/app/api/matches/[matchId]/route.ts
Normal file
251
src/app/api/matches/[matchId]/route.ts
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
// /app/api/matches/[id]/route.ts
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { prisma } from '@/app/lib/prisma'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/app/lib/auth'
|
||||||
|
import {
|
||||||
|
isFuture,
|
||||||
|
buildCommunityFuturePayload,
|
||||||
|
buildDefaultPayload,
|
||||||
|
} from './_builders'
|
||||||
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
|
/* ───────────────────────── GET ───────────────────────── */
|
||||||
|
export async function GET(_: Request, { params }: { params: { matchId: string } }) {
|
||||||
|
const id = params.matchId
|
||||||
|
if (!id) return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const m = await prisma.match.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
teamA: { include: { leader: true } },
|
||||||
|
teamB: { include: { leader: true } },
|
||||||
|
players: { include: { user: true, stats: true, team: true } },
|
||||||
|
teamAUsers: { include: { team: true } },
|
||||||
|
teamBUsers: { include: { team: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!m) return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
m.matchType === 'community' && isFuture(m)
|
||||||
|
? await buildCommunityFuturePayload(m)
|
||||||
|
: buildDefaultPayload(m)
|
||||||
|
|
||||||
|
return NextResponse.json(payload, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`GET /matches/${params.matchId} failed:`, err)
|
||||||
|
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────── PUT ───────────────────────── */
|
||||||
|
export async function PUT(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||||
|
const id = params.matchId
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
const me = session?.user
|
||||||
|
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||||
|
|
||||||
|
// Erwartet: players: Array<{ steamId: string; teamId: string | null }>
|
||||||
|
const body = await req.json()
|
||||||
|
const input = (body?.players ?? []) as Array<{ steamId?: string; teamId?: string | null }>
|
||||||
|
|
||||||
|
const match = await prisma.match.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
teamA: { include: { leader: true } },
|
||||||
|
teamB: { include: { leader: true } },
|
||||||
|
teamAUsers: true,
|
||||||
|
teamBUsers: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||||
|
|
||||||
|
// Eingaben säubern + deduplizieren
|
||||||
|
const bySteam = new Map<string, { steamId: string; teamId: string | null }>()
|
||||||
|
for (const p of input) {
|
||||||
|
if (typeof p?.steamId === 'string' && p.steamId.trim()) {
|
||||||
|
bySteam.set(p.steamId.trim(), { steamId: p.steamId.trim(), teamId: p.teamId ?? null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unique = Array.from(bySteam.values())
|
||||||
|
|
||||||
|
const isAdmin = !!me.isAdmin
|
||||||
|
const isLeaderA = !!match.teamAId && match.teamA?.leader?.steamId === me.steamId
|
||||||
|
const isLeaderB = !!match.teamBId && match.teamB?.leader?.steamId === me.steamId
|
||||||
|
|
||||||
|
// Community-Roster-Mitgliedschaft
|
||||||
|
const rosterA = new Set(match.teamAUsers.map(u => u.steamId))
|
||||||
|
const rosterB = new Set(match.teamBUsers.map(u => u.steamId))
|
||||||
|
const isInA = rosterA.has(me.steamId)
|
||||||
|
const isInB = rosterB.has(me.steamId)
|
||||||
|
|
||||||
|
// Welche Seite darf der User ändern?
|
||||||
|
let editorSide: 'A' | 'B' | 'both' | null = null
|
||||||
|
if (isAdmin) editorSide = 'both'
|
||||||
|
else if (isLeaderA || isInA) editorSide = 'A'
|
||||||
|
else if (isLeaderB || isInB) editorSide = 'B'
|
||||||
|
else editorSide = null
|
||||||
|
|
||||||
|
if (!editorSide) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
const hasTeamIds = Boolean(match.teamAId && match.teamBId)
|
||||||
|
|
||||||
|
// Seite zuordnen (mit/ohne echte Team-IDs)
|
||||||
|
const incomingA = unique.filter(p => hasTeamIds ? p.teamId === match.teamAId : rosterA.has(p.steamId))
|
||||||
|
const incomingB = unique.filter(p => hasTeamIds ? p.teamId === match.teamBId : rosterB.has(p.steamId))
|
||||||
|
|
||||||
|
// Validierung: nur eigene Seite prüfen
|
||||||
|
if (!isAdmin) {
|
||||||
|
if (editorSide === 'A') {
|
||||||
|
const invalid = incomingA.some(p => hasTeamIds ? p.teamId !== match.teamAId : !rosterA.has(p.steamId))
|
||||||
|
if (invalid) return NextResponse.json({ error: 'Ungültige Spielerzuweisung (A)' }, { status: 403 })
|
||||||
|
}
|
||||||
|
if (editorSide === 'B') {
|
||||||
|
const invalid = incomingB.some(p => hasTeamIds ? p.teamId !== match.teamBId : !rosterB.has(p.steamId))
|
||||||
|
if (invalid) return NextResponse.json({ error: 'Ungültige Spielerzuweisung (B)' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SteamId-Listen für die Relation
|
||||||
|
const aSteamIds = Array.from(new Set(incomingA.map(p => p.steamId)))
|
||||||
|
const bSteamIds = Array.from(new Set(incomingB.map(p => p.steamId)))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 1) teamAUsers / teamBUsers SETZEN (nur die Seite(n), die editiert werden)
|
||||||
|
const data: any = {}
|
||||||
|
if (editorSide === 'both' || editorSide === 'A') {
|
||||||
|
data.teamAUsers = {
|
||||||
|
set: [], // erst leeren
|
||||||
|
connect: aSteamIds.map(steamId => ({ steamId })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (editorSide === 'both' || editorSide === 'B') {
|
||||||
|
data.teamBUsers = {
|
||||||
|
set: [],
|
||||||
|
connect: bSteamIds.map(steamId => ({ steamId })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(data).length) {
|
||||||
|
await tx.match.update({ where: { id }, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) (optional) matchPlayer für die geänderten Seiten synchron halten
|
||||||
|
// – so bleiben Teilnehmer + spätere Stats konsistent.
|
||||||
|
if (editorSide === 'both' || editorSide === 'A') {
|
||||||
|
if (hasTeamIds) {
|
||||||
|
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamAId! } })
|
||||||
|
if (aSteamIds.length) {
|
||||||
|
await tx.matchPlayer.createMany({
|
||||||
|
data: aSteamIds.map(steamId => ({ matchId: id, steamId, teamId: match.teamAId! })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Community ohne teamId → anhand Roster löschen
|
||||||
|
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterA) } } })
|
||||||
|
if (aSteamIds.length) {
|
||||||
|
await tx.matchPlayer.createMany({
|
||||||
|
data: aSteamIds.map(steamId => ({ matchId: id, steamId, teamId: null })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (editorSide === 'both' || editorSide === 'B') {
|
||||||
|
if (hasTeamIds) {
|
||||||
|
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamBId! } })
|
||||||
|
if (bSteamIds.length) {
|
||||||
|
await tx.matchPlayer.createMany({
|
||||||
|
data: bSteamIds.map(steamId => ({ matchId: id, steamId, teamId: match.teamBId! })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterB) } } })
|
||||||
|
if (bSteamIds.length) {
|
||||||
|
await tx.matchPlayer.createMany({
|
||||||
|
data: bSteamIds.map(steamId => ({ matchId: id, steamId, teamId: null })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Antwort wie GET
|
||||||
|
const updated = await prisma.match.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
teamA: { include: { leader: true } },
|
||||||
|
teamB: { include: { leader: true } },
|
||||||
|
players: { include: { user: true, stats: true, team: true } },
|
||||||
|
teamAUsers: { include: { team: true } },
|
||||||
|
teamBUsers: { include: { team: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!updated) return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })
|
||||||
|
|
||||||
|
await sendServerSSEMessage({
|
||||||
|
type: 'match-lineup-updated',
|
||||||
|
payload: {
|
||||||
|
matchId: updated.id,
|
||||||
|
teamA: updated.teamAUsers.map(u => u.steamId),
|
||||||
|
teamB: updated.teamBUsers.map(u => u.steamId),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trennung für Response (gleich wie bisher)
|
||||||
|
const setA = new Set(updated.teamAUsers.map(u => u.steamId))
|
||||||
|
const setB = new Set(updated.teamBUsers.map(u => u.steamId))
|
||||||
|
const playersA = updated.players.filter(p => setA.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'Team A' }))
|
||||||
|
const playersB = updated.players.filter(p => setB.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'Team B' }))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: updated.id,
|
||||||
|
title: updated.title,
|
||||||
|
description: updated.description,
|
||||||
|
demoDate: updated.demoDate,
|
||||||
|
matchType: updated.matchType,
|
||||||
|
roundCount: updated.roundCount,
|
||||||
|
map: updated.map,
|
||||||
|
teamA: {
|
||||||
|
id: updated.teamAId ?? null,
|
||||||
|
name: updated.teamAUsers[0]?.team?.name ?? updated.teamA?.name ?? 'Team A',
|
||||||
|
logo: updated.teamAUsers[0]?.team?.logo ?? updated.teamA?.logo ?? null,
|
||||||
|
score: updated.scoreA,
|
||||||
|
players: playersA,
|
||||||
|
},
|
||||||
|
teamB: {
|
||||||
|
id: updated.teamBId ?? null,
|
||||||
|
name: updated.teamBUsers[0]?.team?.name ?? updated.teamB?.name ?? 'Team B',
|
||||||
|
logo: updated.teamBUsers[0]?.team?.logo ?? updated.teamB?.logo ?? null,
|
||||||
|
score: updated.scoreB,
|
||||||
|
players: playersB,
|
||||||
|
},
|
||||||
|
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`PUT /matches/${id} failed:`, err)
|
||||||
|
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ───────────────────────── DELETE ───────────────────────── */
|
||||||
|
export async function DELETE(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||||
|
const id = params.matchId
|
||||||
|
const session = await getServerSession(authOptions(req))
|
||||||
|
if (!session?.user?.isAdmin) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||||
|
try {
|
||||||
|
await prisma.matchPlayer.deleteMany({ where: { matchId: id } })
|
||||||
|
await prisma.match.delete({ where: { id } })
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`DELETE /matches/${id} failed:`, err)
|
||||||
|
return NextResponse.json({ error: 'Failed to delete match' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import { getServerSession } from 'next-auth'
|
|
||||||
import { baseAuthOptions } from '@/app/lib/auth'
|
|
||||||
import { prisma } from '@/app/lib/prisma'
|
|
||||||
import { NextResponse } from 'next/server'
|
|
||||||
import type { InvitedPlayer } from '@/app/types/team'
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getServerSession(baseAuthOptions)
|
|
||||||
|
|
||||||
if (!session?.user?.steamId) {
|
|
||||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const team = await prisma.team.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ leader: { steamId: session.user.steamId } },
|
|
||||||
{ activePlayers: { has: session.user.steamId } },
|
|
||||||
{ inactivePlayers: { has: session.user.steamId } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
leader: true,
|
|
||||||
matchesAsTeamA: {
|
|
||||||
include: {
|
|
||||||
teamA: true,
|
|
||||||
teamB: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
matchesAsTeamB: {
|
|
||||||
include: {
|
|
||||||
teamA: true,
|
|
||||||
teamB: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
invites: {
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
return NextResponse.json({ team: null }, { status: 200 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const steamIds = [...team.activePlayers, ...team.inactivePlayers]
|
|
||||||
|
|
||||||
const playerData = await prisma.user.findMany({
|
|
||||||
where: { steamId: { in: steamIds } },
|
|
||||||
select: {
|
|
||||||
steamId: true,
|
|
||||||
name: true,
|
|
||||||
avatar: true,
|
|
||||||
location: true,
|
|
||||||
premierRank: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const activePlayers = team.activePlayers
|
|
||||||
.map((id) => playerData.find((m) => m.steamId === id))
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
const inactivePlayers = team.inactivePlayers
|
|
||||||
.map((id) => playerData.find((m) => m.steamId === id))
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
const invitedPlayers: InvitedPlayer[] = Array.from(
|
|
||||||
new Map(
|
|
||||||
team.invites.map(invite => {
|
|
||||||
const u = invite.user
|
|
||||||
return [
|
|
||||||
u.steamId,
|
|
||||||
{
|
|
||||||
steamId: u.steamId,
|
|
||||||
name: u.name ?? 'Unbekannt',
|
|
||||||
avatar: u.avatar ?? '/assets/img/avatars/default.png',
|
|
||||||
location: u.location ?? '',
|
|
||||||
premierRank: u.premierRank ?? 0,
|
|
||||||
invitationId: invite.id,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
).values()
|
|
||||||
).sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
|
|
||||||
const matches = [...team.matchesAsTeamA, ...team.matchesAsTeamB]
|
|
||||||
.filter(m => m.teamA && m.teamB)
|
|
||||||
.sort((a, b) => new Date(a.matchDate).getTime() - new Date(b.matchDate).getTime())
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
team: {
|
|
||||||
id: team.id,
|
|
||||||
name: team.name,
|
|
||||||
logo: team.logo,
|
|
||||||
leader: team.leader?.steamId ?? null,
|
|
||||||
activePlayers,
|
|
||||||
inactivePlayers,
|
|
||||||
invitedPlayers,
|
|
||||||
matches,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler in /api/team:', error)
|
|
||||||
return NextResponse.json({ error: 'Interner Fehler beim Laden des Teams' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
62
src/app/components/Alert.tsx
Normal file
62
src/app/components/Alert.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
type AlertProps = {
|
||||||
|
type?: 'solid' | 'soft'
|
||||||
|
color?:
|
||||||
|
| 'dark'
|
||||||
|
| 'secondary'
|
||||||
|
| 'info'
|
||||||
|
| 'success'
|
||||||
|
| 'danger'
|
||||||
|
| 'warning'
|
||||||
|
| 'light'
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Alert({
|
||||||
|
type = 'solid',
|
||||||
|
color = 'info',
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: AlertProps) {
|
||||||
|
const baseClasses =
|
||||||
|
'mt-2 text-sm rounded-lg p-2'
|
||||||
|
const variantClasses: Record<string, Record<string, string>> = {
|
||||||
|
solid: {
|
||||||
|
dark: 'bg-gray-800 text-white dark:bg-white dark:text-neutral-800',
|
||||||
|
secondary: 'bg-gray-500 text-white',
|
||||||
|
info: 'bg-blue-600 text-white dark:bg-blue-500',
|
||||||
|
success: 'bg-teal-500 text-white',
|
||||||
|
danger: 'bg-red-500 text-white',
|
||||||
|
warning: 'bg-yellow-500 text-white',
|
||||||
|
light: 'bg-white text-gray-600',
|
||||||
|
},
|
||||||
|
soft: {
|
||||||
|
dark: 'bg-gray-100 border border-gray-200 text-gray-800 dark:bg-white/10 dark:border-white/20 dark:text-white',
|
||||||
|
secondary: 'bg-gray-50 border border-gray-200 text-gray-600 dark:bg-white/10 dark:border-white/10 dark:text-neutral-400',
|
||||||
|
info: 'bg-blue-100 border border-blue-200 text-blue-800 dark:bg-blue-800/10 dark:border-blue-900 dark:text-blue-500',
|
||||||
|
success: 'bg-teal-100 border border-teal-200 text-teal-800 dark:bg-teal-800/10 dark:border-teal-900 dark:text-teal-500',
|
||||||
|
danger: 'bg-red-100 border border-red-200 text-red-800 dark:bg-red-800/10 dark:border-red-900 dark:text-red-500',
|
||||||
|
warning: 'bg-yellow-100 border border-yellow-200 text-yellow-800 dark:bg-yellow-800/10 dark:border-yellow-900 dark:text-yellow-500',
|
||||||
|
light: 'bg-white/10 border border-white/10 text-white',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={clsx(
|
||||||
|
baseClasses,
|
||||||
|
variantClasses[type][color],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ReactNode, forwardRef, useState, useRef, useEffect } from 'react'
|
import { ReactNode, forwardRef, useState, useRef, useEffect, ButtonHTMLAttributes } from 'react'
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
title?: string
|
title?: string
|
||||||
@ -14,7 +14,7 @@ type ButtonProps = {
|
|||||||
className?: string
|
className?: string
|
||||||
dropDirection?: "up" | "down" | "auto"
|
dropDirection?: "up" | "down" | "auto"
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
} & ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
|
||||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
{
|
{
|
||||||
@ -28,7 +28,8 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
className,
|
className,
|
||||||
dropDirection = "down",
|
dropDirection = "down",
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
...rest
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@ -147,6 +148,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|||||||
className={classes}
|
className={classes}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
{...modalAttributes}
|
{...modalAttributes}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{children ?? title}
|
{children ?? title}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
// Dropdown.tsx
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
export type DropdownItem = {
|
export type DropdownItem = {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ type DroppableZoneProps = {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
activeDragItem: Player | null
|
activeDragItem: Player | null
|
||||||
saveSuccess?: boolean
|
saveSuccess?: boolean
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DroppableZone({
|
export function DroppableZone({
|
||||||
@ -17,6 +18,7 @@ export function DroppableZone({
|
|||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
saveSuccess = false,
|
saveSuccess = false,
|
||||||
|
className,
|
||||||
}: DroppableZoneProps) {
|
}: DroppableZoneProps) {
|
||||||
const { isOver, setNodeRef } = useDroppable({ id })
|
const { isOver, setNodeRef } = useDroppable({ id })
|
||||||
const { over } = useDndContext()
|
const { over } = useDndContext()
|
||||||
@ -33,7 +35,8 @@ export function DroppableZone({
|
|||||||
'w-full rounded-lg p-4 transition-colors min-h-[200px]',
|
'w-full rounded-lg p-4 transition-colors min-h-[200px]',
|
||||||
isOverZone
|
isOverZone
|
||||||
? 'border-2 border-dashed border-blue-400 bg-blue-400/10'
|
? 'border-2 border-dashed border-blue-400 bg-blue-400/10'
|
||||||
: 'border border-gray-300 dark:border-neutral-700'
|
: 'border border-gray-300 dark:border-neutral-700',
|
||||||
|
className
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -64,7 +67,7 @@ export function DroppableZone({
|
|||||||
|
|
||||||
{/* Hier sitzt der Droppable-Ref */}
|
{/* Hier sitzt der Droppable-Ref */}
|
||||||
<div ref={setNodeRef} className={zoneClasses}>
|
<div ref={setNodeRef} className={zoneClasses}>
|
||||||
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
<div className="grid gap-4 justify-start grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
273
src/app/components/EditMatchMetaModal.tsx
Normal file
273
src/app/components/EditMatchMetaModal.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
// app/components/EditMatchMetaModal.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import Modal from '@/app/components/Modal'
|
||||||
|
import Alert from '@/app/components/Alert'
|
||||||
|
import Select from '@/app/components/Select'
|
||||||
|
import { MAP_OPTIONS } from '../lib/mapOptions'
|
||||||
|
|
||||||
|
type TeamOption = { id: string; name: string; logo?: string | null }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
show: boolean
|
||||||
|
onClose: () => void
|
||||||
|
matchId: string
|
||||||
|
defaultTitle?: string | null
|
||||||
|
defaultTeamAId?: string | null
|
||||||
|
defaultTeamBId?: string | null
|
||||||
|
defaultTeamAName?: string | null
|
||||||
|
defaultTeamBName?: string | null
|
||||||
|
defaultDateISO?: string | null
|
||||||
|
defaultMap?: string | null
|
||||||
|
defaultVetoLeadMinutes?: number
|
||||||
|
onSaved?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditMatchMetaModal({
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
matchId,
|
||||||
|
defaultTitle = '',
|
||||||
|
defaultTeamAId,
|
||||||
|
defaultTeamBId,
|
||||||
|
defaultTeamAName,
|
||||||
|
defaultTeamBName,
|
||||||
|
defaultDateISO,
|
||||||
|
defaultMap,
|
||||||
|
defaultVetoLeadMinutes = 60,
|
||||||
|
onSaved,
|
||||||
|
}: Props) {
|
||||||
|
// -------- state
|
||||||
|
const [title, setTitle] = useState(defaultTitle ?? '')
|
||||||
|
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
||||||
|
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
||||||
|
const [date, setDate] = useState<string>(() => {
|
||||||
|
if (!defaultDateISO) return ''
|
||||||
|
const d = new Date(defaultDateISO)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
})
|
||||||
|
const [mapKey, setMapKey] = useState<string>(defaultMap ?? 'lobby_mapveto')
|
||||||
|
const [vetoLead, setVetoLead] = useState<number>(defaultVetoLeadMinutes)
|
||||||
|
|
||||||
|
const [teams, setTeams] = useState<TeamOption[]>([])
|
||||||
|
const [loadingTeams, setLoadingTeams] = useState(false)
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// -------- load teams when open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return
|
||||||
|
setLoadingTeams(true)
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||||||
|
if (!res.ok) throw new Error(`Team-API: ${res.status}`)
|
||||||
|
const data = (await res.json()) as TeamOption[]
|
||||||
|
setTeams((Array.isArray(data) ? data : []).filter(t => t?.id && t?.name))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EditMatchMetaModal] load teams failed:', e)
|
||||||
|
setTeams([])
|
||||||
|
} finally {
|
||||||
|
setLoadingTeams(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [show])
|
||||||
|
|
||||||
|
// -------- reset defaults on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) return
|
||||||
|
setTitle(defaultTitle ?? '')
|
||||||
|
setTeamAId(defaultTeamAId ?? '')
|
||||||
|
setTeamBId(defaultTeamBId ?? '')
|
||||||
|
setMapKey(defaultMap ?? 'lobby_mapveto')
|
||||||
|
setVetoLead(defaultVetoLeadMinutes)
|
||||||
|
if (defaultDateISO) {
|
||||||
|
const d = new Date(defaultDateISO)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
setDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`)
|
||||||
|
} else {
|
||||||
|
setDate('')
|
||||||
|
}
|
||||||
|
setSaved(false)
|
||||||
|
setError(null)
|
||||||
|
}, [
|
||||||
|
show,
|
||||||
|
defaultTitle,
|
||||||
|
defaultTeamAId,
|
||||||
|
defaultTeamBId,
|
||||||
|
defaultDateISO,
|
||||||
|
defaultMap,
|
||||||
|
defaultVetoLeadMinutes,
|
||||||
|
])
|
||||||
|
|
||||||
|
// -------- derived: options
|
||||||
|
const teamOptionsA = useMemo(() => {
|
||||||
|
// Team B nicht in A auswählbar machen
|
||||||
|
return teams
|
||||||
|
.filter(t => t.id !== teamBId)
|
||||||
|
.map(t => ({ value: t.id, label: t.name }));
|
||||||
|
}, [teams, teamBId]);
|
||||||
|
|
||||||
|
const teamOptionsB = useMemo(() => {
|
||||||
|
// Team A nicht in B auswählbar machen
|
||||||
|
return teams
|
||||||
|
.filter(t => t.id !== teamAId)
|
||||||
|
.map(t => ({ value: t.id, label: t.name }));
|
||||||
|
}, [teams, teamAId]);
|
||||||
|
|
||||||
|
const mapOptions = useMemo(
|
||||||
|
() => MAP_OPTIONS.map(m => ({ value: m.key, label: m.label })),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// -------- validation
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
if (saving) return false
|
||||||
|
if (!date) return false
|
||||||
|
if (teamAId && teamBId && teamAId === teamBId) return false
|
||||||
|
return true
|
||||||
|
}, [saving, date, teamAId, teamBId])
|
||||||
|
|
||||||
|
// -------- save
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
title: title || null,
|
||||||
|
teamAId: teamAId || null,
|
||||||
|
teamBId: teamBId || null,
|
||||||
|
matchDate: date ? new Date(date).toISOString() : null,
|
||||||
|
map: mapKey || null,
|
||||||
|
vetoLeadMinutes: Number.isFinite(Number(vetoLead)) ? Number(vetoLead) : 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/matches/${matchId}/meta`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(j?.error || 'Update fehlgeschlagen')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaved(true)
|
||||||
|
onSaved?.()
|
||||||
|
onClose()
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[EditMatchMetaModal] save error:', e)
|
||||||
|
setError(e?.message || 'Speichern fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!show) return null
|
||||||
|
|
||||||
|
// Platzhalter mit aktuellem Namen (falls Options noch laden)
|
||||||
|
const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …')
|
||||||
|
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
id="edit-match-meta"
|
||||||
|
title="Matchdaten bearbeiten"
|
||||||
|
show
|
||||||
|
onClose={onClose}
|
||||||
|
onSave={handleSave}
|
||||||
|
closeButtonTitle={saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'}
|
||||||
|
closeButtonColor={saved ? 'green' : 'blue'}
|
||||||
|
disableSave={!canSave}
|
||||||
|
maxWidth="sm:max-w-2xl"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Alert type="soft" color="danger" className="mb-3">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Titel */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Titel</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
placeholder="z.B. Scrim vs. XYZ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team A */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Team A</label>
|
||||||
|
<Select
|
||||||
|
options={teamOptionsA}
|
||||||
|
value={teamAId}
|
||||||
|
onChange={setTeamAId}
|
||||||
|
placeholder={teamAPlaceholder}
|
||||||
|
dropDirection="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team B */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Team B</label>
|
||||||
|
<Select
|
||||||
|
options={teamOptionsB}
|
||||||
|
value={teamBId}
|
||||||
|
onChange={setTeamBId}
|
||||||
|
placeholder={teamBPlaceholder}
|
||||||
|
dropDirection="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datum/Uhrzeit */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Datum & Uhrzeit</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||||
|
value={date}
|
||||||
|
onChange={e => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
<div className="col-span-2 sm:col-span-1">
|
||||||
|
<label className="block text-sm font-medium mb-1">Map</label>
|
||||||
|
<Select
|
||||||
|
options={mapOptions}
|
||||||
|
value={mapKey}
|
||||||
|
onChange={setMapKey}
|
||||||
|
placeholder="Map wählen …"
|
||||||
|
dropDirection="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Veto-Lead */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Map-Veto lead (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||||
|
value={vetoLead}
|
||||||
|
onChange={e => setVetoLead(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Zeit vor Matchstart, zu der das Veto öffnet (Standard 60).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -72,54 +72,53 @@ export default function EditMatchPlayersModal (props: Props) {
|
|||||||
/* ---- Komplett-Spielerliste laden ------------------------ */
|
/* ---- Komplett-Spielerliste laden ------------------------ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) return
|
if (!show) return
|
||||||
if (!team?.id) return
|
|
||||||
|
if (!team?.id) {
|
||||||
|
// ❗ Kein verknüpftes Team – zeig einen klaren Hinweis
|
||||||
|
setPlayers([])
|
||||||
|
setSelected([])
|
||||||
|
setError('Kein Team mit diesem Match verknüpft (fehlende Team-ID).')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/team/${team.id}`)
|
const res = await fetch(`/api/team/${encodeURIComponent(team.id)}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(`Team-API: ${res.status}`)
|
setError(`Team-API: ${res.status}`)
|
||||||
setPlayers([]) // leer, aber gleich nicht mehr "loading"
|
setPlayers([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
// 🔧 Normalizer: akzeptiert string | Player
|
// 👉 Hier brauchst du KEIN Normalizer mehr, wenn deine /api/team-Route
|
||||||
const toPlayer = (x: any): Player =>
|
// (wie zuletzt angepasst) bereits Player-Objekte liefert.
|
||||||
typeof x === 'string'
|
const all = [
|
||||||
? { steamId: x, name: 'Unbekannt', avatar: '' }
|
|
||||||
: x
|
|
||||||
|
|
||||||
const raw = [
|
|
||||||
...(data.activePlayers ?? []),
|
...(data.activePlayers ?? []),
|
||||||
...(data.inactivePlayers ?? []),
|
...(data.inactivePlayers ?? []),
|
||||||
]
|
]
|
||||||
|
.filter((p: Player) => !!p?.steamId)
|
||||||
// 🔧 Dedupe robust
|
.filter((p: Player, i: number, arr: Player[]) => arr.findIndex(x => x.steamId === p.steamId) === i)
|
||||||
const byId = new Map<string, Player>()
|
.sort((a: Player, b: Player) => (a.name || '').localeCompare(b.name || ''))
|
||||||
for (const x of raw) {
|
|
||||||
const p = toPlayer(x)
|
|
||||||
if (p?.steamId && !byId.has(p.steamId)) byId.set(p.steamId, p)
|
|
||||||
}
|
|
||||||
|
|
||||||
const all = Array.from(byId.values())
|
|
||||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
|
||||||
|
|
||||||
setPlayers(all)
|
setPlayers(all)
|
||||||
setSelected(myInit) // initiale Auswahl übernehmen
|
setSelected(myInit) // initiale Auswahl aus Props
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
console.error('[EditMatchPlayersModal] load error:', e)
|
console.error('[EditMatchPlayersModal] load error:', e)
|
||||||
setError('Laden fehlgeschlagen')
|
setError('Laden fehlgeschlagen')
|
||||||
setPlayers([])
|
setPlayers([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false) // ✅ nie in der Schleife hängen bleiben
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}, [show, team?.id]) // ⚠️ myInit hier nicht nötig
|
}, [show, team?.id])
|
||||||
|
|
||||||
/* ---- Drag’n’Drop-Handler -------------------------------- */
|
/* ---- Drag’n’Drop-Handler -------------------------------- */
|
||||||
const onDragStart = ({ active }: any) => {
|
const onDragStart = ({ active }: any) => {
|
||||||
@ -189,7 +188,7 @@ export default function EditMatchPlayersModal (props: Props) {
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
closeButtonTitle={
|
closeButtonTitle={
|
||||||
saved ? '✓ gespeichert' : saving ? 'Speichern …' : 'Speichern'
|
saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'
|
||||||
}
|
}
|
||||||
closeButtonColor={saved ? 'green' : 'blue'}
|
closeButtonColor={saved ? 'green' : 'blue'}
|
||||||
disableSave={!canEdit || saving || !team?.id}
|
disableSave={!canEdit || saving || !team?.id}
|
||||||
@ -222,6 +221,7 @@ export default function EditMatchPlayersModal (props: Props) {
|
|||||||
{/* --- Zone: Aktuell eingestellte Spieler ------------- */}
|
{/* --- Zone: Aktuell eingestellte Spieler ------------- */}
|
||||||
<DroppableZone
|
<DroppableZone
|
||||||
id="active"
|
id="active"
|
||||||
|
className="mb-4"
|
||||||
label={`Eingesetzte Spieler (${active.length} / 5)`}
|
label={`Eingesetzte Spieler (${active.length} / 5)`}
|
||||||
activeDragItem={dragItem}
|
activeDragItem={dragItem}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,36 +1,34 @@
|
|||||||
|
// MapVetoBanner.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
import type { Match } from '../types/match'
|
|
||||||
import type { MapVetoState } from '../types/mapveto'
|
import type { MapVetoState } from '../types/mapveto'
|
||||||
|
|
||||||
type Props = {
|
type Props = { match: any; initialNow: number }
|
||||||
match: Match
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MapVetoBanner({ match }: Props) {
|
export default function MapVetoBanner({ match, initialNow }: Props) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
|
// ✅ eine Uhr, deterministisch bei Hydration (kommt als Prop vom Server)
|
||||||
|
const [now, setNow] = useState(initialNow)
|
||||||
|
|
||||||
const [state, setState] = useState<MapVetoState | null>(null)
|
const [state, setState] = useState<MapVetoState | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null)
|
setError(null)
|
||||||
const r = await fetch(`/api/matches/${match.id}/map-vote`, { cache: 'no-store' })
|
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({}))
|
||||||
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
||||||
}
|
}
|
||||||
const json = await r.json()
|
const json = await r.json()
|
||||||
if (!json || !Array.isArray(json.steps)) {
|
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
|
||||||
throw new Error('Ungültige Serverantwort (steps fehlt)')
|
|
||||||
}
|
|
||||||
setState(json)
|
setState(json)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setState(null)
|
setState(null)
|
||||||
@ -38,41 +36,39 @@ export default function MapVetoBanner({ match }: Props) {
|
|||||||
}
|
}
|
||||||
}, [match.id])
|
}, [match.id])
|
||||||
|
|
||||||
|
// ✅ tickt NUR im Client, nach Hydration
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
// Live-Refresh via SSE
|
// Live-Refresh via SSE
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent || lastEvent.type !== 'map-vote-updated') return
|
||||||
if (lastEvent.type !== 'map-vote-updated') return
|
if (lastEvent.payload?.matchId !== match.id) return
|
||||||
const mId = lastEvent.payload?.matchId
|
|
||||||
if (mId !== match.id) return
|
|
||||||
load()
|
load()
|
||||||
}, [lastEvent, match.id, load])
|
}, [lastEvent, match.id, load])
|
||||||
|
|
||||||
// Öffnungslogik (Fallback: 1h vor Match-/Demozeit)
|
// Öffnet 1h vor Match-/Demotermin (stabil, ohne Date.now() im Render)
|
||||||
const opensAt = useMemo(() => {
|
const opensAt = useMemo(() => {
|
||||||
if (state?.opensAt) return new Date(state.opensAt).getTime()
|
if (state?.opensAt) return new Date(state.opensAt).getTime()
|
||||||
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
|
const base = new Date(match.matchDate ?? match.demoDate ?? initialNow)
|
||||||
return base.getTime() - 60 * 60 * 1000
|
return base.getTime() - 60 * 60 * 1000
|
||||||
}, [state?.opensAt, match.matchDate, match.demoDate])
|
}, [state?.opensAt, match.matchDate, match.demoDate, initialNow])
|
||||||
|
|
||||||
const [nowTs, setNowTs] = useState(() => Date.now())
|
const isOpen = now >= opensAt
|
||||||
useEffect(() => {
|
const msToOpen = Math.max(opensAt - now, 0)
|
||||||
const t = setInterval(() => setNowTs(Date.now()), 1000)
|
|
||||||
return () => clearInterval(t)
|
|
||||||
}, [])
|
|
||||||
const isOpen = nowTs >= opensAt
|
|
||||||
const msToOpen = Math.max(opensAt - nowTs, 0)
|
|
||||||
|
|
||||||
// Wer ist am Zug?
|
|
||||||
const current = state?.steps?.[state.currentIndex]
|
const current = state?.steps?.[state.currentIndex]
|
||||||
const whoIsUp = current?.teamId
|
const whoIsUp = current?.teamId
|
||||||
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
|
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
// Rechte nur für Text
|
// ⚠️ leader ist bei dir ein Player-Objekt → .steamId vergleichen
|
||||||
const isLeaderA = !!session?.user?.steamId && match.teamA?.leader === session.user.steamId
|
const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session.user.steamId
|
||||||
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader === session.user.steamId
|
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session.user.steamId
|
||||||
const isAdmin = !!session?.user?.isAdmin
|
const isAdmin = !!session?.user?.isAdmin
|
||||||
const iCanAct = Boolean(
|
const iCanAct = Boolean(
|
||||||
isOpen &&
|
isOpen &&
|
||||||
@ -83,7 +79,7 @@ export default function MapVetoBanner({ match }: Props) {
|
|||||||
(current.teamId === match.teamB?.id && isLeaderB))
|
(current.teamId === match.teamB?.id && isLeaderB))
|
||||||
)
|
)
|
||||||
|
|
||||||
const gotoFullPage = () => router.push(`/match-details/${match.id}/map-vote`)
|
const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`)
|
||||||
|
|
||||||
const cardClasses =
|
const cardClasses =
|
||||||
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
|
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
// /app/components/MapVotePanel.tsx
|
// /app/components/MapVetoPanel.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
|
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
|
||||||
|
import type React from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
import { mapNameMap } from '../lib/mapNameMap'
|
import { MAP_OPTIONS } from '../lib/mapOptions'
|
||||||
import MapVoteProfileCard from './MapVetoProfileCard'
|
import MapVoteProfileCard from './MapVetoProfileCard'
|
||||||
import type { Match, MatchPlayer } from '../types/match'
|
import type { Match, MatchPlayer } from '../types/match'
|
||||||
import type { MapVetoState } from '../types/mapveto'
|
import type { MapVetoState } from '../types/mapveto'
|
||||||
|
import Button from './Button'
|
||||||
import { Player } from '../types/team'
|
import { Player } from '../types/team'
|
||||||
|
|
||||||
type Props = { match: Match }
|
type Props = { match: Match }
|
||||||
@ -15,8 +17,8 @@ type Props = { match: Match }
|
|||||||
const getTeamLogo = (logo?: string | null) =>
|
const getTeamLogo = (logo?: string | null) =>
|
||||||
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
|
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
|
||||||
|
|
||||||
const HOLD_MS = 1200 // Dauer zum Gedrückthalten (ms)
|
const HOLD_MS = 1200
|
||||||
const COMPLETE_THRESHOLD = 1.00 // ab diesem Fortschritt gilt "fertig"
|
const COMPLETE_THRESHOLD = 1.0
|
||||||
|
|
||||||
export default function MapVetoPanel({ match }: Props) {
|
export default function MapVetoPanel({ match }: Props) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
@ -43,30 +45,27 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
// --- Rollen ---
|
// --- Rollen ---
|
||||||
const me = session?.user
|
const me = session?.user
|
||||||
const isAdmin = !!me?.isAdmin
|
const isAdmin = !!me?.isAdmin
|
||||||
|
|
||||||
const leaderAId = state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId ?? null
|
|
||||||
const leaderBId = state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId ?? null
|
|
||||||
|
|
||||||
const isLeaderA = !!me?.steamId && match.teamA?.leader?.steamId === me.steamId
|
const isLeaderA = !!me?.steamId && match.teamA?.leader?.steamId === me.steamId
|
||||||
const isLeaderB = !!me?.steamId && match.teamB?.leader?.steamId === me.steamId
|
const isLeaderB = !!me?.steamId && match.teamB?.leader?.steamId === me.steamId
|
||||||
|
|
||||||
console.log("me.steamId: ", me?.steamId);
|
const canActForTeamId = useCallback(
|
||||||
console.log("match.teamA?.leader?.steamId: ", match.teamA?.leader?.steamId);
|
(teamId?: string | null) => {
|
||||||
console.log("match.teamB?.leader?.steamId: ", match.teamB?.leader?.steamId);
|
|
||||||
|
|
||||||
const canActForTeamId = useCallback((teamId?: string | null) => {
|
|
||||||
if (!teamId) return false
|
if (!teamId) return false
|
||||||
if (isAdmin) return true
|
if (isAdmin) return true
|
||||||
return (teamId === match.teamA?.id && isLeaderA) ||
|
return (
|
||||||
|
(teamId === match.teamA?.id && isLeaderA) ||
|
||||||
(teamId === match.teamB?.id && isLeaderB)
|
(teamId === match.teamB?.id && isLeaderB)
|
||||||
}, [isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id])
|
)
|
||||||
|
},
|
||||||
|
[isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id],
|
||||||
|
)
|
||||||
|
|
||||||
// --- Laden / Reload ---
|
// --- Laden / Reload ---
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/matches/${match.id}/map-vote`, { cache: 'no-store' })
|
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const j = await r.json().catch(() => ({}))
|
const j = await r.json().catch(() => ({}))
|
||||||
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
||||||
@ -84,7 +83,9 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
}
|
}
|
||||||
}, [match.id])
|
}, [match.id])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
// --- SSE: live nachladen ---
|
// --- SSE: live nachladen ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -96,13 +97,16 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
}, [lastEvent, match.id, load])
|
}, [lastEvent, match.id, load])
|
||||||
|
|
||||||
// --- Abgeleitet ---
|
// --- Abgeleitet ---
|
||||||
const opensAt = useMemo(() => state?.opensAt ? new Date(state.opensAt).getTime() : null, [state?.opensAt])
|
const opensAt = useMemo(
|
||||||
|
() => (state?.opensAt ? new Date(state.opensAt).getTime() : null),
|
||||||
|
[state?.opensAt],
|
||||||
|
)
|
||||||
const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch
|
const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch
|
||||||
const msToOpen = Math.max((opensAt ?? opensAtTs) - nowTs, 0)
|
const msToOpen = Math.max((opensAt ?? opensAtTs) - nowTs, 0)
|
||||||
|
|
||||||
const currentStep = state?.steps?.[state?.currentIndex ?? 0]
|
const currentStep = state?.steps?.[state?.currentIndex ?? 0]
|
||||||
const isMyTurn = Boolean(
|
const isMyTurn = Boolean(
|
||||||
isOpen && !state?.locked && currentStep?.teamId && canActForTeamId(currentStep.teamId)
|
isOpen && !state?.locked && currentStep?.teamId && canActForTeamId(currentStep.teamId),
|
||||||
)
|
)
|
||||||
|
|
||||||
const mapPool = state?.mapPool ?? []
|
const mapPool = state?.mapPool ?? []
|
||||||
@ -110,19 +114,19 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
// Map -> (action, teamId) wenn bereits entschieden
|
// Map -> (action, teamId) wenn bereits entschieden
|
||||||
const decisionByMap = useMemo(() => {
|
const decisionByMap = useMemo(() => {
|
||||||
const map = new Map<string, { action: 'ban' | 'pick' | 'decider'; teamId: string | null }>()
|
const map = new Map<string, { action: 'ban' | 'pick' | 'decider'; teamId: string | null }>()
|
||||||
for (const s of (state?.steps ?? [])) {
|
for (const s of state?.steps ?? []) {
|
||||||
if (s.map) map.set(s.map, { action: s.action as any, teamId: s.teamId ?? null })
|
if (s.map) map.set(s.map, { action: s.action as any, teamId: s.teamId ?? null })
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}, [state?.steps])
|
}, [state?.steps])
|
||||||
|
|
||||||
const fmt = (k: string) => mapNameMap[k]?.name ?? k
|
const fmt = (k: string) => MAP_OPTIONS.find((m) => m.key === k)?.label ?? k
|
||||||
|
|
||||||
// --- Aktionen ---
|
// --- Aktionen ---
|
||||||
const handlePickOrBan = async (map: string) => {
|
const handlePickOrBan = async (map: string) => {
|
||||||
if (!isMyTurn || !currentStep) return
|
if (!isMyTurn || !currentStep) return
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/matches/${match.id}/map-vote`, {
|
const r = await fetch(`/api/matches/${match.id}/mapvote`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ map }),
|
body: JSON.stringify({ map }),
|
||||||
@ -132,7 +136,19 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
alert(j.message ?? 'Aktion fehlgeschlagen')
|
alert(j.message ?? 'Aktion fehlgeschlagen')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Erfolg -> SSE triggert load()
|
|
||||||
|
// ⬅️ Optimistisches Update, bevor SSE kommt:
|
||||||
|
setState(prev =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
steps: prev.steps.map((s, idx) =>
|
||||||
|
idx === prev.currentIndex ? { ...s, map } : s
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
)
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
alert('Netzwerkfehler')
|
alert('Netzwerkfehler')
|
||||||
}
|
}
|
||||||
@ -142,7 +158,7 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
const holdStartRef = useRef<number | null>(null)
|
const holdStartRef = useRef<number | null>(null)
|
||||||
const holdMapRef = useRef<string | null>(null)
|
const holdMapRef = useRef<string | null>(null)
|
||||||
const submittedRef = useRef<boolean>(false) // gegen Doppel-Submit
|
const submittedRef = useRef<boolean>(false)
|
||||||
const [progressByMap, setProgressByMap] = useState<Record<string, number>>({})
|
const [progressByMap, setProgressByMap] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
const resetHold = useCallback(() => {
|
const resetHold = useCallback(() => {
|
||||||
@ -153,19 +169,23 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
submittedRef.current = false
|
submittedRef.current = false
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const finishAndSubmit = useCallback((map: string) => {
|
const finishAndSubmit = useCallback(
|
||||||
|
(map: string) => {
|
||||||
if (submittedRef.current) return
|
if (submittedRef.current) return
|
||||||
submittedRef.current = true
|
submittedRef.current = true
|
||||||
setTimeout(() => handlePickOrBan(map), 10)
|
setTimeout(() => handlePickOrBan(map), 10)
|
||||||
}, [handlePickOrBan])
|
},
|
||||||
|
[handlePickOrBan],
|
||||||
|
)
|
||||||
|
|
||||||
const stepHold = useCallback((ts: number) => {
|
const stepHold = useCallback(
|
||||||
|
(ts: number) => {
|
||||||
if (!holdStartRef.current || !holdMapRef.current) return
|
if (!holdStartRef.current || !holdMapRef.current) return
|
||||||
const elapsed = ts - holdStartRef.current
|
const elapsed = ts - holdStartRef.current
|
||||||
const p = Math.min(1, elapsed / HOLD_MS)
|
const p = Math.min(1, elapsed / HOLD_MS)
|
||||||
const map = holdMapRef.current
|
const map = holdMapRef.current
|
||||||
|
|
||||||
setProgressByMap(prev => ({ ...prev, [map]: p }))
|
setProgressByMap((prev) => ({ ...prev, [map]: p }))
|
||||||
|
|
||||||
if (p >= COMPLETE_THRESHOLD) {
|
if (p >= COMPLETE_THRESHOLD) {
|
||||||
const doneMap = map
|
const doneMap = map
|
||||||
@ -174,18 +194,24 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rafRef.current = requestAnimationFrame(stepHold)
|
rafRef.current = requestAnimationFrame(stepHold)
|
||||||
}, [resetHold, finishAndSubmit])
|
},
|
||||||
|
[resetHold, finishAndSubmit],
|
||||||
|
)
|
||||||
|
|
||||||
const onHoldStart = useCallback((map: string, allowed: boolean) => {
|
const onHoldStart = useCallback(
|
||||||
|
(map: string, allowed: boolean) => {
|
||||||
if (!allowed) return
|
if (!allowed) return
|
||||||
resetHold()
|
resetHold()
|
||||||
holdMapRef.current = map
|
holdMapRef.current = map
|
||||||
holdStartRef.current = performance.now()
|
holdStartRef.current = performance.now()
|
||||||
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
|
setProgressByMap((prev) => ({ ...prev, [map]: 0 }))
|
||||||
rafRef.current = requestAnimationFrame(stepHold)
|
rafRef.current = requestAnimationFrame(stepHold)
|
||||||
}, [stepHold, resetHold])
|
},
|
||||||
|
[stepHold, resetHold],
|
||||||
|
)
|
||||||
|
|
||||||
const cancelOrSubmitIfComplete = useCallback((map: string) => {
|
const cancelOrSubmitIfComplete = useCallback(
|
||||||
|
(map: string) => {
|
||||||
const p = progressByMap[map] ?? 0
|
const p = progressByMap[map] ?? 0
|
||||||
if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) {
|
if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) {
|
||||||
resetHold()
|
resetHold()
|
||||||
@ -194,9 +220,22 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
}
|
}
|
||||||
if (holdMapRef.current === map) {
|
if (holdMapRef.current === map) {
|
||||||
resetHold()
|
resetHold()
|
||||||
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
|
setProgressByMap((prev) => ({ ...prev, [map]: 0 }))
|
||||||
}
|
}
|
||||||
}, [progressByMap, resetHold, finishAndSubmit])
|
},
|
||||||
|
[progressByMap, resetHold, finishAndSubmit],
|
||||||
|
)
|
||||||
|
|
||||||
|
const deciderChooserTeamId = useMemo(() => {
|
||||||
|
const steps = state?.steps ?? []
|
||||||
|
const decIdx = steps.findIndex(s => s.action === 'decider')
|
||||||
|
if (decIdx < 0) return null
|
||||||
|
for (let i = decIdx - 1; i >= 0; i--) {
|
||||||
|
const s = steps[i]
|
||||||
|
if (s.action === 'ban' && s.teamId) return s.teamId
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [state?.steps])
|
||||||
|
|
||||||
// Touch-Unterstützung
|
// Touch-Unterstützung
|
||||||
const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => {
|
const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => {
|
||||||
@ -208,18 +247,125 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
cancelOrSubmitIfComplete(map)
|
cancelOrSubmitIfComplete(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading && !state) return <div className="p-4">Lade Map-Voting…</div>
|
// --- Spielerlisten ableiten (Hooks bleiben IMMER aktiv) ---
|
||||||
if (error && !state) return <div className="p-4 text-red-600">{error}</div>
|
const playersA = useMemo<MatchPlayer[]>(() => {
|
||||||
|
// 0) Bevorzugt: bereits vorbereitete Team-Spieler am Match selbst
|
||||||
|
const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined
|
||||||
|
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
|
||||||
|
|
||||||
const playersA = match.teamA.players as unknown as MatchPlayer[]
|
// 1) Klassischer Weg: match.players via Roster (teamAUsers) filtern
|
||||||
const playersB = match.teamB.players as unknown as MatchPlayer[]
|
const all = (match as any).players as MatchPlayer[] | undefined
|
||||||
|
const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined
|
||||||
|
if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) {
|
||||||
|
const setA = new Set(teamAUsers.map(u => u.steamId))
|
||||||
|
return all.filter(p => setA.has(p.user.steamId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback: teamId am Player (falls vorhanden)
|
||||||
|
if (Array.isArray(all) && match.teamA?.id) {
|
||||||
|
return all.filter(p => (p as any).team?.id === match.teamA?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Letzter Fallback: aus dem Veto-State (kommt aus /mapvote)
|
||||||
|
const vetoPlayers = state?.teams?.teamA?.players as
|
||||||
|
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
if (Array.isArray(vetoPlayers) && vetoPlayers.length) {
|
||||||
|
return vetoPlayers.map((p): MatchPlayer => ({
|
||||||
|
user: {
|
||||||
|
steamId: p.steamId,
|
||||||
|
name: p.name ?? 'Unbekannt',
|
||||||
|
avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
|
||||||
|
},
|
||||||
|
// wichtig: undefined statt null
|
||||||
|
stats: undefined,
|
||||||
|
// falls dein MatchPlayer einen string akzeptiert:
|
||||||
|
// team: (match as any)?.teamA?.name ?? 'Team A',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}, [match, state?.teams?.teamA?.players])
|
||||||
|
|
||||||
|
// ⬇️ ersetzt den bisherigen playersB-Block
|
||||||
|
const playersB = useMemo<MatchPlayer[]>(() => {
|
||||||
|
const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined
|
||||||
|
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
|
||||||
|
|
||||||
|
const all = (match as any).players as MatchPlayer[] | undefined
|
||||||
|
const teamBUsers = (match as any).teamBUsers as { steamId: string }[] | undefined
|
||||||
|
if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) {
|
||||||
|
const setB = new Set(teamBUsers.map(u => u.steamId))
|
||||||
|
return all.filter(p => setB.has(p.user.steamId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(all) && match.teamB?.id) {
|
||||||
|
return all.filter(p => (p as any).team?.id === match.teamB?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vetoPlayers = state?.teams?.teamB?.players as
|
||||||
|
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
if (Array.isArray(vetoPlayers) && vetoPlayers.length) {
|
||||||
|
return vetoPlayers.map((p): MatchPlayer => ({
|
||||||
|
user: {
|
||||||
|
steamId: p.steamId,
|
||||||
|
name: p.name ?? 'Unbekannt',
|
||||||
|
avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
|
||||||
|
},
|
||||||
|
stats: undefined,
|
||||||
|
// team: (match as any)?.teamB?.name ?? 'Team B',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}, [match, state?.teams?.teamB?.players])
|
||||||
|
|
||||||
|
const showLoading = isLoading && !state
|
||||||
|
const showError = !!error && !state
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
{showLoading ? (
|
||||||
|
<div className="p-4">Lade Map-Voting…</div>
|
||||||
|
) : showError ? (
|
||||||
|
<div className="p-4 text-red-600">{error}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold">Map-Vote</h3>
|
<h3 className="text-lg font-semibold">Map-Vote</h3>
|
||||||
<div className="text-sm opacity-80">Modus: BO{match.bestOf ?? state?.bestOf ?? 3}</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm opacity-80">
|
||||||
|
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="ml-3"
|
||||||
|
title="Map-Vote zurücksetzen"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('Map-Vote wirklich zurücksetzen? Alle bisherigen Picks/Bans gehen verloren.')) return
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' })
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = await r.json().catch(() => ({}))
|
||||||
|
alert(j.message ?? 'Reset fehlgeschlagen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SSE feuert ohnehin; zusätzlich lokal nachladen:
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
alert('Netzwerkfehler beim Reset')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Countdown / Status */}
|
{/* Countdown / Status */}
|
||||||
@ -231,6 +377,37 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Countdown / Status ganz oben und größer */}
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
{state?.locked ? (
|
||||||
|
<span className="block text-lg font-semibold px-3 py-2 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 text-center">
|
||||||
|
✅ Veto abgeschlossen
|
||||||
|
</span>
|
||||||
|
) : isOpen ? (
|
||||||
|
isMyTurn ? (
|
||||||
|
<span className="block text-lg font-semibold px-3 py-2 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200 text-center">
|
||||||
|
✋ Halte gedrückt, um zu bestätigen
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="block text-lg font-semibold px-3 py-2 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200 text-center">
|
||||||
|
⏳ Wartet auf
|
||||||
|
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
|
||||||
|
(Leader/Admin)
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="block text-lg font-semibold px-3 py-2 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 text-center">
|
||||||
|
Öffnet in {formatCountdown(msToOpen)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<span className="block mt-2 text-base font-medium px-3 py-2 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 text-center">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hauptbereich */}
|
{/* Hauptbereich */}
|
||||||
{state && (
|
{state && (
|
||||||
<div className="mt-2 flex items-start gap-4 justify-between">
|
<div className="mt-2 flex items-start gap-4 justify-between">
|
||||||
@ -244,13 +421,20 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
avatar={p.user.avatar}
|
avatar={p.user.avatar}
|
||||||
rank={p.stats?.rankNew ?? 0}
|
rank={p.stats?.rankNew ?? 0}
|
||||||
matchType={match.matchType}
|
matchType={match.matchType}
|
||||||
isLeader={(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) === p.user.steamId}
|
isLeader={
|
||||||
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) && !state.locked}
|
(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) ===
|
||||||
|
p.user.steamId
|
||||||
|
}
|
||||||
|
isActiveTurn={
|
||||||
|
!!currentStep?.teamId &&
|
||||||
|
currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) &&
|
||||||
|
!state.locked
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mitte – Maps untereinander (kompakt + Hold-to-confirm) */}
|
{/* Mitte – Maps (Hold-to-confirm) */}
|
||||||
<main className="max-w-sm flex-shrink-0">
|
<main className="max-w-sm flex-shrink-0">
|
||||||
<ul className="flex flex-col gap-1.5">
|
<ul className="flex flex-col gap-1.5">
|
||||||
{mapPool.map((map) => {
|
{mapPool.map((map) => {
|
||||||
@ -261,35 +445,77 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
const taken = !!status
|
const taken = !!status
|
||||||
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
|
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
|
||||||
|
|
||||||
|
// Intent-Farben basierend auf aktuellem Step
|
||||||
|
const intent = isAvailable ? currentStep?.action : null
|
||||||
|
const intentStyles =
|
||||||
|
intent === 'ban'
|
||||||
|
? {
|
||||||
|
ring: '',
|
||||||
|
border: '',
|
||||||
|
hover: 'hover:bg-red-50 dark:hover:bg-red-950',
|
||||||
|
progress: 'bg-red-200/60 dark:bg-red-800/40',
|
||||||
|
}
|
||||||
|
: intent === 'pick'
|
||||||
|
? {
|
||||||
|
ring: '',
|
||||||
|
border: '',
|
||||||
|
hover: 'hover:bg-green-50 dark:hover:bg-green-950',
|
||||||
|
progress: 'bg-green-200/60 dark:bg-green-800/40',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ring: '',
|
||||||
|
border: '',
|
||||||
|
hover: 'hover:bg-blue-50 dark:hover:bg-blue-950',
|
||||||
|
progress: 'bg-blue-200/60 dark:bg-blue-800/40',
|
||||||
|
}
|
||||||
|
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
'relative flex items-center justify-between gap-2 rounded-md border p-2.5 transition select-none'
|
'relative flex items-center justify-between gap-2 rounded-md border p-2.5 transition select-none'
|
||||||
const visualClasses =
|
|
||||||
taken
|
const visualTaken =
|
||||||
? (status === 'ban'
|
status === 'ban'
|
||||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-900/40 text-red-800 dark:text-red-200'
|
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-900/40 text-red-800 dark:text-red-200'
|
||||||
: status === 'pick' || status === 'decider'
|
: status === 'pick' || status === 'decider'
|
||||||
? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40'
|
? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40'
|
||||||
: 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700')
|
: 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700'
|
||||||
: (isAvailable
|
|
||||||
? 'bg-white dark:bg-neutral-900 border-blue-400 ring-1 ring-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950 cursor-pointer'
|
|
||||||
: 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700')
|
|
||||||
|
|
||||||
const pickedByA = status === 'pick' && teamId === match.teamA?.id
|
const visualAvailable = `bg-white dark:bg-neutral-900 ${intentStyles.border} ring-1 ${intentStyles.ring} ${intentStyles.hover} cursor-pointer`
|
||||||
const pickedByB = status === 'pick' && teamId === match.teamB?.id
|
const visualDisabled = 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700'
|
||||||
const showLeftLogo = pickedByA
|
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
|
||||||
const showRightLogo = pickedByB
|
|
||||||
|
|
||||||
const leftLogo = getTeamLogo(match.teamA?.logo)
|
// Decider-Team bestimmen (falls nötig)
|
||||||
const rightLogo = getTeamLogo(match.teamB?.logo)
|
const effectiveTeamId =
|
||||||
|
status === 'decider'
|
||||||
|
? deciderChooserTeamId
|
||||||
|
: decision?.teamId ?? null
|
||||||
|
|
||||||
|
const pickedByA =
|
||||||
|
(status === 'pick' || status === 'decider') && effectiveTeamId === match.teamA?.id
|
||||||
|
const pickedByB =
|
||||||
|
(status === 'pick' || status === 'decider') && effectiveTeamId === match.teamB?.id
|
||||||
|
|
||||||
const progress = progressByMap[map] ?? 0
|
const progress = progressByMap[map] ?? 0
|
||||||
const showProgress = isAvailable && progress > 0 && progress < 1
|
const showProgress = isAvailable && progress > 0 && progress < 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={map}>
|
<li
|
||||||
<button
|
key={map}
|
||||||
type="button"
|
className="grid grid-cols-[24px_1fr_24px] items-center gap-2"
|
||||||
className={`${baseClasses} ${visualClasses} w-full text-left`}
|
>
|
||||||
|
{/* linker Slot */}
|
||||||
|
{pickedByA ? (
|
||||||
|
<img
|
||||||
|
src={getTeamLogo(match.teamA?.logo)}
|
||||||
|
alt={match.teamA?.name ?? 'Team A'}
|
||||||
|
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Button */}
|
||||||
|
<Button
|
||||||
|
className={`${baseClasses} ${visualClasses} w-full text-left relative`}
|
||||||
disabled={!isAvailable}
|
disabled={!isAvailable}
|
||||||
title={
|
title={
|
||||||
taken
|
taken
|
||||||
@ -309,31 +535,19 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
onTouchEnd={onTouchEnd(map)}
|
onTouchEnd={onTouchEnd(map)}
|
||||||
onTouchCancel={onTouchEnd(map)}
|
onTouchCancel={onTouchEnd(map)}
|
||||||
>
|
>
|
||||||
{/* Fortschrittsbalken (unter dem Inhalt) */}
|
{/* Fortschrittsbalken */}
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-y-0 left-0 rounded-md bg-blue-200/60 dark:bg-blue-800/40 pointer-events-none z-0"
|
className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-0`}
|
||||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Linkes Logo bei Pick durch Team A */}
|
{/* Inhalt */}
|
||||||
{showLeftLogo && (
|
|
||||||
<img
|
|
||||||
src={leftLogo}
|
|
||||||
alt={match.teamA?.name ?? 'Team A'}
|
|
||||||
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900 z-[1]"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inhalt mittig (Mapname + technischer Key) */}
|
|
||||||
<div className="flex-1 min-w-0 relative z-[1] flex flex-col items-center justify-center text-center">
|
<div className="flex-1 min-w-0 relative z-[1] flex flex-col items-center justify-center text-center">
|
||||||
<span className="text-[13px] font-medium truncate">
|
<span className="text-[13px] font-medium truncate">{fmt(map)}</span>
|
||||||
{fmt(map)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* rotes X bei Ban – über dem Namen */}
|
|
||||||
{status === 'ban' && (
|
{status === 'ban' && (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@ -349,47 +563,22 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Rechtes Logo bei Pick durch Team B */}
|
{/* rechter Slot */}
|
||||||
{showRightLogo && (
|
{pickedByB ? (
|
||||||
<img
|
<img
|
||||||
src={rightLogo}
|
src={getTeamLogo(match.teamB?.logo)}
|
||||||
alt={match.teamB?.name ?? 'Team B'}
|
alt={match.teamB?.name ?? 'Team B'}
|
||||||
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900 z-[1]"
|
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-6 h-6" />
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Footer-Status */}
|
|
||||||
<div className="mt-3 text-sm flex flex-wrap items-center gap-3">
|
|
||||||
{state.locked ? (
|
|
||||||
<span className="px-2 py-1 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
|
|
||||||
Veto abgeschlossen
|
|
||||||
</span>
|
|
||||||
) : isOpen ? (
|
|
||||||
isMyTurn ? (
|
|
||||||
<span className="px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
|
|
||||||
Halte gedrückt, um zu bestätigen
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-1 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
|
|
||||||
Wartet auf
|
|
||||||
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
|
|
||||||
(Leader/Admin)
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<span className="px-2 py-1 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Rechts – Team B */}
|
{/* Rechts – Team B */}
|
||||||
@ -402,13 +591,22 @@ export default function MapVetoPanel({ match }: Props) {
|
|||||||
avatar={p.user.avatar}
|
avatar={p.user.avatar}
|
||||||
rank={p.stats?.rankNew ?? 0}
|
rank={p.stats?.rankNew ?? 0}
|
||||||
matchType={match.matchType}
|
matchType={match.matchType}
|
||||||
isLeader={(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === p.user.steamId}
|
isLeader={
|
||||||
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) && !state.locked}
|
(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) ===
|
||||||
|
p.user.steamId
|
||||||
|
}
|
||||||
|
isActiveTurn={
|
||||||
|
!!currentStep?.teamId &&
|
||||||
|
currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) &&
|
||||||
|
!state.locked
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
/* ────────────────────────────────────────────────────────────────
|
/* ────────────────────────────────────────────────────────────────
|
||||||
/app/components/MatchDetails.tsx
|
/app/components/MatchDetails.tsx
|
||||||
Zeigt pro Team einen eigenen „Spieler bearbeiten“-Button und öffnet
|
- Zeigt pro Team einen eigenen „Spieler bearbeiten“-Button
|
||||||
das Modal nur für das angeklickte Team.
|
- Öffnet das Modal nur für das angeklickte Team
|
||||||
|
- Reagiert auf SSE-Events (match-lineup-updated / matches-updated)
|
||||||
─────────────────────────────────────────────────────────────────*/
|
─────────────────────────────────────────────────────────────────*/
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
@ -14,23 +15,28 @@ import { de } from 'date-fns/locale'
|
|||||||
import Table from './Table'
|
import Table from './Table'
|
||||||
import PremierRankBadge from './PremierRankBadge'
|
import PremierRankBadge from './PremierRankBadge'
|
||||||
import CompRankBadge from './CompRankBadge'
|
import CompRankBadge from './CompRankBadge'
|
||||||
|
import EditMatchMetaModal from './EditMatchMetaModal'
|
||||||
import EditMatchPlayersModal from './EditMatchPlayersModal'
|
import EditMatchPlayersModal from './EditMatchPlayersModal'
|
||||||
import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B'
|
import type { EditSide } from './EditMatchPlayersModal'
|
||||||
|
|
||||||
import type { Match, MatchPlayer } from '../types/match'
|
import type { Match, MatchPlayer } from '../types/match'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { mapNameMap } from '../lib/mapNameMap'
|
import { MAP_OPTIONS } from '../lib/mapOptions'
|
||||||
import MapVetoBanner from './MapVetoBanner'
|
import MapVetoBanner from './MapVetoBanner'
|
||||||
import MapVetoPanel from './MapVetoPanel'
|
|
||||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
import { Player, Team } from '../types/team'
|
import { Team } from '../types/team'
|
||||||
|
import Alert from './Alert'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { MATCH_EVENTS } from '../lib/sseEvents'
|
||||||
|
|
||||||
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
|
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
|
||||||
|
|
||||||
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
|
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
|
||||||
const kdr = (k?: number, d?: number) =>
|
const kdr = (k?: number, d?: number) =>
|
||||||
typeof k === 'number' && typeof d === 'number'
|
typeof k === 'number' && typeof d === 'number'
|
||||||
? d === 0 ? '∞' : (k / d).toFixed(2)
|
? d === 0
|
||||||
|
? '∞'
|
||||||
|
: (k / d).toFixed(2)
|
||||||
: '-'
|
: '-'
|
||||||
|
|
||||||
const adr = (dmg?: number, rounds?: number) =>
|
const adr = (dmg?: number, rounds?: number) =>
|
||||||
@ -38,12 +44,17 @@ const adr = (dmg?: number, rounds?: number) =>
|
|||||||
? (dmg / rounds).toFixed(1)
|
? (dmg / rounds).toFixed(1)
|
||||||
: '-'
|
: '-'
|
||||||
|
|
||||||
|
const normalizeMapKey = (raw?: string) =>
|
||||||
|
(raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
|
||||||
|
|
||||||
/* ─────────────────── Komponente ─────────────────────────────── */
|
/* ─────────────────── Komponente ─────────────────────────────── */
|
||||||
export function MatchDetails ({ match }: { match: Match }) {
|
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isAdmin = !!session?.user?.isAdmin
|
const isAdmin = !!session?.user?.isAdmin
|
||||||
|
const [now, setNow] = useState(initialNow)
|
||||||
|
const [editMetaOpen, setEditMetaOpen] = useState(false)
|
||||||
|
|
||||||
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
||||||
const me = session?.user
|
const me = session?.user
|
||||||
@ -53,34 +64,64 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
const canEditA = isAdmin || isLeaderA
|
const canEditA = isAdmin || isLeaderA
|
||||||
const canEditB = isAdmin || isLeaderB
|
const canEditB = isAdmin || isLeaderB
|
||||||
|
|
||||||
const isMapVetoOpen = !!match.mapVeto?.isOpen
|
|
||||||
|
|
||||||
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
|
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
|
||||||
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
|
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
|
||||||
|
|
||||||
/* ─── Map ─────────────────────────────────────────────────── */
|
/* ─── Map ─────────────────────────────────────────────────── */
|
||||||
const normalizeMapKey = (raw?: string) =>
|
|
||||||
(raw ?? '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\.bsp$/,'')
|
|
||||||
.replace(/^.*\//,'')
|
|
||||||
|
|
||||||
const mapKey = normalizeMapKey(match.map)
|
const mapKey = normalizeMapKey(match.map)
|
||||||
const mapLabel = mapNameMap[mapKey]?.name ?? (match.map ?? 'Unbekannte Map')
|
const mapLabel =
|
||||||
|
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
|
||||||
|
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapveto')?.label ??
|
||||||
|
'Unbekannte Map'
|
||||||
|
|
||||||
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
|
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
|
||||||
const dateString = match.matchDate ?? match.demoDate
|
const dateString = match.matchDate ?? match.demoDate
|
||||||
const isFutureMatch = !!dateString && new Date(dateString).getTime() > Date.now()
|
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
|
||||||
|
|
||||||
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
|
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
|
||||||
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
||||||
|
|
||||||
|
/* ─── Live-Uhr (für Veto-Zeitpunkt) ───────────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const vetoOpensAtTs = useMemo(() => {
|
||||||
|
const base = match.mapVeto?.opensAt
|
||||||
|
? new Date(match.mapVeto.opensAt).getTime()
|
||||||
|
: new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() - 60 * 60 * 1000
|
||||||
|
return base
|
||||||
|
}, [match.mapVeto?.opensAt, match.matchDate, match.demoDate, initialNow])
|
||||||
|
|
||||||
|
const endDate = new Date(vetoOpensAtTs)
|
||||||
|
const mapVetoStarted = (match.mapVeto?.isOpen ?? false) || now >= vetoOpensAtTs
|
||||||
|
|
||||||
|
const showEditA = canEditA && !mapVetoStarted
|
||||||
|
const showEditB = canEditB && !mapVetoStarted
|
||||||
|
|
||||||
|
/* ─── SSE-Listener ─────────────────────────────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastEvent) return
|
||||||
|
|
||||||
|
// Match gelöscht? → zurück zur Liste
|
||||||
|
if (lastEvent.type === 'match-deleted' && lastEvent.payload?.matchId === match.id) {
|
||||||
|
router.replace('/schedule')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Match-Events → Seite frisch rendern
|
||||||
|
if (MATCH_EVENTS.has(lastEvent.type) && lastEvent.payload?.matchId === match.id) {
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
}, [lastEvent, match.id, router])
|
||||||
|
|
||||||
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
|
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
|
||||||
const ColGroup = () => (
|
const ColGroup = () => (
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '24%' }} />
|
<col style={{ width: '24%' }} />
|
||||||
<col style={{ width: '8%' }} />
|
<col style={{ width: '8%' }} />
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
{Array.from({ length: 13 }).map((_, i) => (
|
||||||
<col key={i} style={{ width: '5.666%' }} />
|
<col key={i} style={{ width: '5.666%' }} />
|
||||||
))}
|
))}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
@ -96,78 +137,81 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
alert(j.message ?? 'Löschen fehlgeschlagen')
|
alert(j.message ?? 'Löschen fehlgeschlagen')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Zurück zur Matchliste
|
router.push('/schedule')
|
||||||
router.push('/schedule') // ggf. an deinen Pfad anpassen
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[MatchDetails] delete failed', e)
|
console.error('[MatchDetails] delete failed', e)
|
||||||
alert('Löschen fehlgeschlagen.')
|
alert('Löschen fehlgeschlagen.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Spieler-Tabelle ─────────────────────────────────────── */
|
/* ─── Spieler-Tabelle (pure; keine Hooks hier drin!) ──────── */
|
||||||
const renderTable = (players: MatchPlayer[]) => {
|
const renderTable = (players: MatchPlayer[]) => {
|
||||||
const sorted = [...players].sort(
|
const sorted = [...players].sort(
|
||||||
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
|
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Wenn das aktuell angezeigte Match serverseitig gelöscht wurde:
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastEvent) return
|
|
||||||
if (lastEvent.type !== 'match-deleted') return
|
|
||||||
const deletedId = lastEvent.payload?.matchId
|
|
||||||
if (deletedId !== match.id) return
|
|
||||||
router.replace('/schedule')
|
|
||||||
}, [lastEvent, match.id, router])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastEvent) return
|
|
||||||
if (lastEvent.type !== 'matches-updated') return
|
|
||||||
|
|
||||||
// kurz verifizieren, ob es das Match noch gibt
|
|
||||||
;(async () => {
|
|
||||||
const r = await fetch(`/api/matches/${match.id}`, { cache: 'no-store' })
|
|
||||||
if (r.status === 404) router.replace('/schedule')
|
|
||||||
})()
|
|
||||||
}, [lastEvent, match.id, router])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table>
|
<Table>
|
||||||
<ColGroup />
|
<ColGroup />
|
||||||
<Table.Head>
|
<Table.Head>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
{['Spieler','Rank','Aim','K','A','D','1K','2K','3K','4K','5K',
|
{[
|
||||||
'K/D','ADR','HS%','Damage'].map(h => (
|
'Spieler',
|
||||||
<Table.Cell key={h} as="th">{h}</Table.Cell>
|
'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>
|
||||||
))}
|
))}
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Head>
|
</Table.Head>
|
||||||
|
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{sorted.map(p => (
|
{sorted.map((p) => (
|
||||||
<Table.Row
|
<Table.Row key={p.user.steamId}>
|
||||||
key={p.user.steamId}
|
<Table.Cell
|
||||||
|
className="py-1 flex items-center gap-2"
|
||||||
|
hoverable
|
||||||
|
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||||
>
|
>
|
||||||
<Table.Cell className="py-1 flex items-center gap-2" hoverable onClick={() => router.push(`/profile/${p.user.steamId}`)} >
|
|
||||||
<img
|
<img
|
||||||
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
||||||
alt={p.user.name}
|
alt={p.user.name}
|
||||||
className="w-8 h-8 rounded-full mr-3"
|
className="w-8 h-8 rounded-full mr-3"
|
||||||
/>
|
/>
|
||||||
<div className='font-semibold text-base'>
|
<div className="font-semibold text-base">{p.user.name ?? 'Unbekannt'}</div>
|
||||||
{p.user.name ?? 'Unbekannt'}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex items-center gap-[6px]">
|
||||||
{match.matchType === 'premier'
|
{match.matchType === 'premier' ? (
|
||||||
? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
<PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||||
: <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
|
) : (
|
||||||
{match.matchType === 'premier' &&
|
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||||
typeof p.stats?.rankChange === 'number' && (
|
)}
|
||||||
<span className={`text-sm ${
|
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
|
||||||
p.stats.rankChange > 0 ? 'text-green-500'
|
<span
|
||||||
: p.stats.rankChange < 0 ? 'text-red-500' : ''}`}>
|
className={`text-sm ${
|
||||||
|
p.stats.rankChange > 0
|
||||||
|
? 'text-green-500'
|
||||||
|
: p.stats.rankChange < 0
|
||||||
|
? 'text-red-500'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{p.stats.rankChange > 0 ? '+' : ''}
|
{p.stats.rankChange > 0 ? '+' : ''}
|
||||||
{p.stats.rankChange}
|
{p.stats.rankChange}
|
||||||
</span>
|
</span>
|
||||||
@ -199,11 +243,6 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Ausgabe-Datum ───────────────────────────────────────── */
|
|
||||||
const readableDate = dateString
|
|
||||||
? format(new Date(dateString), 'PPpp', { locale: de })
|
|
||||||
: 'Unbekannt'
|
|
||||||
|
|
||||||
/* ─── Render ─────────────────────────────────────────────── */
|
/* ─── Render ─────────────────────────────────────────────── */
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -212,45 +251,48 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
onClick={handleDelete}
|
<Button onClick={() => setEditMetaOpen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md">
|
||||||
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"
|
Match bearbeiten
|
||||||
>
|
</Button>
|
||||||
|
<Button onClick={handleDelete} className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md">
|
||||||
Match löschen
|
Match löschen
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
||||||
|
|
||||||
<div className="text-md">
|
<div className="text-md">
|
||||||
<strong>Teams:</strong>{' '}
|
<strong>Teams:</strong> {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
|
||||||
{match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-md">
|
<div className="text-md">
|
||||||
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MapVetoBanner match={match} />
|
<MapVetoBanner match={match} initialNow={initialNow} />
|
||||||
|
|
||||||
{/* ───────── Team-Blöcke ───────── */}
|
{/* ───────── Team-Blöcke ───────── */}
|
||||||
<div className="border-t pt-4 mt-4 space-y-10">
|
<div className="border-t pt-4 mt-4 space-y-10">
|
||||||
{/* Team A */}
|
{/* Team A */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h2 className="text-xl font-semibold">
|
<h2 className="text-xl font-semibold">{match.teamA?.name ?? 'Team A'}</h2>
|
||||||
{match.teamA?.name ?? 'Team A'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{canEditA && isFutureMatch && (
|
{showEditA && (
|
||||||
|
<Alert type="soft" color="info" className="flex items-center justify-between gap-4">
|
||||||
|
<span>
|
||||||
|
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size="sm"
|
||||||
onClick={() => setEditSide('A')}
|
onClick={() => setEditSide('A')}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg
|
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
>
|
>
|
||||||
Spieler bearbeiten
|
Spieler bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -261,18 +303,38 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h2 className="text-xl font-semibold">
|
<h2 className="text-xl font-semibold">
|
||||||
|
{match.teamB?.logo && (
|
||||||
|
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
match.teamB.logo
|
||||||
|
? `/assets/img/logos/${match.teamB.logo}`
|
||||||
|
: `/assets/img/logos/cs2.webp`
|
||||||
|
}
|
||||||
|
alt="Teamlogo"
|
||||||
|
fill
|
||||||
|
sizes="64px"
|
||||||
|
quality={75}
|
||||||
|
priority={false}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{match.teamB?.name ?? 'Team B'}
|
{match.teamB?.name ?? 'Team B'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{canEditB && isFutureMatch && (
|
{showEditB && (
|
||||||
|
<Alert type="soft" color="info" className="flex items-center justify-between gap-4">
|
||||||
|
<span>
|
||||||
|
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size="sm"
|
||||||
onClick={() => setEditSide('B')}
|
onClick={() => setEditSide('B')}
|
||||||
className="px-3 py-1.5 text-sm rounded-lg
|
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
>
|
>
|
||||||
Spieler bearbeiten
|
Spieler bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -289,9 +351,26 @@ export function MatchDetails ({ match }: { match: Match }) {
|
|||||||
teamA={match.teamA}
|
teamA={match.teamA}
|
||||||
teamB={match.teamB}
|
teamB={match.teamB}
|
||||||
side={editSide}
|
side={editSide}
|
||||||
initialA={teamAPlayers.map(mp => mp.user.steamId)}
|
initialA={teamAPlayers.map((mp) => mp.user.steamId)}
|
||||||
initialB={teamBPlayers.map(mp => mp.user.steamId)}
|
initialB={teamBPlayers.map((mp) => mp.user.steamId)}
|
||||||
onSaved={() => window.location.reload()}
|
onSaved={() => router.refresh()} // sanfter als window.location.reload()
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editMetaOpen && (
|
||||||
|
<EditMatchMetaModal
|
||||||
|
show
|
||||||
|
onClose={() => setEditMetaOpen(false)}
|
||||||
|
matchId={match.id}
|
||||||
|
defaultTitle={match.title}
|
||||||
|
defaultTeamAId={match.teamA?.id ?? null}
|
||||||
|
defaultTeamBId={match.teamB?.id ?? null}
|
||||||
|
defaultTeamAName={match.teamA?.name ?? null}
|
||||||
|
defaultTeamBName={match.teamB?.name ?? null}
|
||||||
|
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
||||||
|
defaultMap={match.map ?? null}
|
||||||
|
defaultVetoLeadMinutes={60}
|
||||||
|
onSaved={() => { router.refresh() }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
// Select.tsx
|
||||||
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
type Option = {
|
import { createPortal } from "react-dom";
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
type Option = { value: string; label: string };
|
||||||
type SelectProps = {
|
type SelectProps = {
|
||||||
options: Option[];
|
options: Option[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@ -14,65 +12,89 @@ type SelectProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Select({ options, placeholder = "Select option...", value, onChange, dropDirection = "down", className }: SelectProps) {
|
export default function Select({
|
||||||
|
options,
|
||||||
|
placeholder = "Select option...",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
dropDirection = "down",
|
||||||
|
className
|
||||||
|
}: SelectProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [direction, setDirection] = useState<"up" | "down">("down");
|
const [direction, setDirection] = useState<"up" | "down">("down");
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const [coords, setCoords] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
if (open && dropDirection === "auto" && buttonRef.current) {
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
requestAnimationFrame(() => {
|
const menuRef = useRef<HTMLUListElement>(null); // 👈 NEU
|
||||||
const rect = buttonRef.current!.getBoundingClientRect();
|
|
||||||
const dropdownHeight = 200;
|
const selectedOption = useMemo(() => options.find(o => o.value === value), [options, value]);
|
||||||
|
|
||||||
|
const computePosition = () => {
|
||||||
|
if (!buttonRef.current) return;
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const dropdownHeight = 240;
|
||||||
const spaceBelow = window.innerHeight - rect.bottom;
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
const spaceAbove = rect.top;
|
const spaceAbove = rect.top;
|
||||||
|
|
||||||
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
|
const dir: "up" | "down" =
|
||||||
setDirection("up");
|
dropDirection === "auto"
|
||||||
} else {
|
? spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? "up" : "down"
|
||||||
setDirection("down");
|
: dropDirection;
|
||||||
}
|
|
||||||
|
setDirection(dir);
|
||||||
|
setCoords({
|
||||||
|
left: Math.round(rect.left),
|
||||||
|
width: Math.round(rect.width),
|
||||||
|
top: dir === "down" ? Math.round(rect.bottom) : Math.round(rect.top)
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
}, [open, dropDirection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
if (!open) return;
|
||||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
computePosition();
|
||||||
setOpen(false);
|
|
||||||
}
|
const onScroll = () => computePosition();
|
||||||
|
const onResize = () => computePosition();
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
}, [open, dropDirection]);
|
||||||
|
|
||||||
|
// Click-outside: ignoriert Klicks im Portal-Menü
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
|
const t = event.target as Node;
|
||||||
|
const clickedInsideRoot = !!rootRef.current?.contains(t);
|
||||||
|
const clickedInsideMenu = !!menuRef.current?.contains(t);
|
||||||
|
if (!clickedInsideRoot && !clickedInsideMenu) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedOption = options.find(o => o.value === value);
|
const Menu = open
|
||||||
|
? createPortal(
|
||||||
return (
|
|
||||||
<div ref={ref} className="relative">
|
|
||||||
<button
|
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(prev => !prev)}
|
|
||||||
className={`relative py-2 px-4 pe-10 w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 ${className}`}
|
|
||||||
>
|
|
||||||
{selectedOption ? selectedOption.label : placeholder}
|
|
||||||
<span className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none">
|
|
||||||
<svg className="w-4 h-4 text-gray-500 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
||||||
<path d="M7 10l5 5 5-5" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<ul
|
<ul
|
||||||
className={`absolute z-50 ${
|
ref={menuRef} // 👈 wichtig
|
||||||
(dropDirection === "auto" ? direction : dropDirection) === "up"
|
className="z-[9999] fixed bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto text-sm dark:bg-neutral-900 dark:border-neutral-700"
|
||||||
? "bottom-full mb-2"
|
style={{
|
||||||
: "top-full mt-2"
|
left: coords.left,
|
||||||
} w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto text-sm dark:bg-neutral-900 dark:border-neutral-700`}
|
top: coords.top,
|
||||||
|
width: coords.width,
|
||||||
|
transform: direction === "up" ? "translateY(calc(-100% - 8px))" : "translateY(8px)"
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map(option => (
|
||||||
<li
|
<li
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -86,8 +108,27 @@ export default function Select({ options, placeholder = "Select option...", valu
|
|||||||
{option.label}
|
{option.label}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>,
|
||||||
)}
|
document.body
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(prev => !prev)}
|
||||||
|
className={`relative py-2 px-4 pe-10 w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 ${className}`}
|
||||||
|
>
|
||||||
|
{selectedOption ? selectedOption.label : placeholder}
|
||||||
|
<span className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none">
|
||||||
|
<svg className="w-4 h-4 text-gray-500 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 10l5 5 5-5" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{Menu}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import Table from '../../../Table'
|
import Table from '../../../Table'
|
||||||
import PremierRankBadge from '../../../PremierRankBadge'
|
import PremierRankBadge from '../../../PremierRankBadge'
|
||||||
import CompRankBadge from '../../../CompRankBadge'
|
import CompRankBadge from '../../../CompRankBadge'
|
||||||
import { mapNameMap } from '@/app/lib/mapNameMap'
|
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
|
||||||
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
import LoadingSpinner from '@/app/components/LoadingSpinner'
|
||||||
|
|
||||||
interface Match {
|
interface Match {
|
||||||
@ -198,7 +198,9 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
|
|||||||
|
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{matches.map(m => {
|
{matches.map(m => {
|
||||||
const mapInfo = mapNameMap[m.map] ?? mapNameMap.lobby_mapveto
|
const mapInfo =
|
||||||
|
MAP_OPTIONS.find(opt => opt.key === m.map) ??
|
||||||
|
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapveto')
|
||||||
const [scoreCT, scoreT] = parseScore(m.score)
|
const [scoreCT, scoreT] = parseScore(m.score)
|
||||||
|
|
||||||
const ownCTSide = m.team !== 'T'
|
const ownCTSide = m.team !== 'T'
|
||||||
@ -221,11 +223,11 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src={`/assets/img/mapicons/${m.map}.webp`}
|
src={`/assets/img/mapicons/${m.map}.webp`}
|
||||||
alt={mapInfo.name}
|
alt={mapInfo?.label}
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
/>
|
/>
|
||||||
{mapInfo.name}
|
{mapInfo?.label}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
// src/lib/mapNameMap.ts
|
|
||||||
|
|
||||||
export const mapNameMap: Record<string, { name: string }> = {
|
|
||||||
de_train: { name: 'Train' },
|
|
||||||
ar_baggage: { name: 'Baggage' },
|
|
||||||
ar_pool_day: { name: 'Pool Day' },
|
|
||||||
ar_shoots: { name: 'Shoots' },
|
|
||||||
cs_agency: { name: 'Agency' },
|
|
||||||
cs_italy: { name: 'Italy' },
|
|
||||||
cs_office: { name: 'Office' },
|
|
||||||
de_ancient: { name: 'Ancient' },
|
|
||||||
de_anubis: { name: 'Anubis' },
|
|
||||||
de_brewery: { name: 'Brewery' },
|
|
||||||
de_dogtown: { name: 'Dogtown' },
|
|
||||||
de_dust2: { name: 'Dust 2' },
|
|
||||||
de_grail: { name: 'Grail' },
|
|
||||||
de_inferno: { name: 'Inferno' },
|
|
||||||
de_jura: { name: 'Jura' },
|
|
||||||
de_mirage: { name: 'Mirage' },
|
|
||||||
de_nuke: { name: 'Nuke' },
|
|
||||||
de_overpass: { name: 'Overpass' },
|
|
||||||
de_vertigo: { name: 'Vertigo' },
|
|
||||||
lobby_mapveto: { name: 'Pick/Ban' },
|
|
||||||
};
|
|
||||||
25
src/app/lib/mapOptions.ts
Normal file
25
src/app/lib/mapOptions.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// src/lib/mapOptions.ts
|
||||||
|
export type MapOption = { key: string; label: string }
|
||||||
|
|
||||||
|
export const MAP_OPTIONS: MapOption[] = [
|
||||||
|
{ key: 'de_train', label: 'Train' },
|
||||||
|
{ key: 'ar_baggage', label: 'Baggage' },
|
||||||
|
{ key: 'ar_pool_day', label: 'Pool Day' },
|
||||||
|
{ key: 'ar_shoots', label: 'Shoots' },
|
||||||
|
{ key: 'cs_agency', label: 'Agency' },
|
||||||
|
{ key: 'cs_italy', label: 'Italy' },
|
||||||
|
{ key: 'cs_office', label: 'Office' },
|
||||||
|
{ key: 'de_ancient', label: 'Ancient' },
|
||||||
|
{ key: 'de_anubis', label: 'Anubis' },
|
||||||
|
{ key: 'de_brewery', label: 'Brewery' },
|
||||||
|
{ key: 'de_dogtown', label: 'Dogtown' },
|
||||||
|
{ key: 'de_dust2', label: 'Dust 2' },
|
||||||
|
{ key: 'de_grail', label: 'Grail' },
|
||||||
|
{ key: 'de_inferno', label: 'Inferno' },
|
||||||
|
{ key: 'de_jura', label: 'Jura' },
|
||||||
|
{ key: 'de_mirage', label: 'Mirage' },
|
||||||
|
{ key: 'de_nuke', label: 'Nuke' },
|
||||||
|
{ key: 'de_overpass', label: 'Overpass' },
|
||||||
|
{ key: 'de_vertigo', label: 'Vertigo' },
|
||||||
|
{ key: 'lobby_mapveto', label: 'Pick/Ban' },
|
||||||
|
]
|
||||||
@ -1,5 +1,4 @@
|
|||||||
// sseEvents.ts
|
// sseEvents.ts
|
||||||
|
|
||||||
export const SSE_EVENT_TYPES = [
|
export const SSE_EVENT_TYPES = [
|
||||||
// Kanonisch
|
// Kanonisch
|
||||||
'team-updated',
|
'team-updated',
|
||||||
@ -24,6 +23,10 @@ export const SSE_EVENT_TYPES = [
|
|||||||
'match-created',
|
'match-created',
|
||||||
'matches-updated',
|
'matches-updated',
|
||||||
'match-deleted',
|
'match-deleted',
|
||||||
|
'match-updated',
|
||||||
|
|
||||||
|
// ➕ neu: gezieltes Event, wenn sich die Aufstellung ändert
|
||||||
|
'match-lineup-updated',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SSEEventType = typeof SSE_EVENT_TYPES[number];
|
export type SSEEventType = typeof SSE_EVENT_TYPES[number];
|
||||||
@ -57,6 +60,15 @@ export const SELF_EVENTS: ReadonlySet<SSEEventType> = new Set([
|
|||||||
'team-leader-self',
|
'team-leader-self',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ➕ neu: Match-bezogene Events als Gruppe
|
||||||
|
export const MATCH_EVENTS: ReadonlySet<SSEEventType> = new Set([
|
||||||
|
'match-created',
|
||||||
|
'matches-updated',
|
||||||
|
'match-deleted',
|
||||||
|
'match-lineup-updated',
|
||||||
|
'match-updated',
|
||||||
|
]);
|
||||||
|
|
||||||
// Event-Typen, die das NotificationCenter betreffen
|
// Event-Typen, die das NotificationCenter betreffen
|
||||||
export const NOTIFICATION_EVENTS: ReadonlySet<SSEEventType> = new Set([
|
export const NOTIFICATION_EVENTS: ReadonlySet<SSEEventType> = new Set([
|
||||||
'notification',
|
'notification',
|
||||||
|
|||||||
17
src/app/match-details/[matchId]/MatchContext.tsx
Normal file
17
src/app/match-details/[matchId]/MatchContext.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// app/match-details/[matchId]/MatchContext.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import type { Match } from '@/app/types/match'
|
||||||
|
|
||||||
|
const Ctx = createContext<Match | null>(null)
|
||||||
|
|
||||||
|
export function useMatch() {
|
||||||
|
const v = useContext(Ctx)
|
||||||
|
if (!v) throw new Error('MatchContext missing — check layout.tsx')
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchProvider({ match, children }:{ match: Match; children: React.ReactNode }) {
|
||||||
|
return <Ctx.Provider value={match}>{children}</Ctx.Provider>
|
||||||
|
}
|
||||||
11
src/app/match-details/[matchId]/MatchDetailsClient.tsx
Normal file
11
src/app/match-details/[matchId]/MatchDetailsClient.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// app/match-details/[matchId]/MatchDetailsClient.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMatch } from './MatchContext'
|
||||||
|
import { MatchDetails } from '@/app/components/MatchDetails'
|
||||||
|
|
||||||
|
export default function MatchDetailsClient() {
|
||||||
|
const match = useMatch()
|
||||||
|
const initialNow = Date.now()
|
||||||
|
return <MatchDetails match={match} initialNow={initialNow} />
|
||||||
|
}
|
||||||
39
src/app/match-details/[matchId]/layout.tsx
Normal file
39
src/app/match-details/[matchId]/layout.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// app/match-details/[matchId]/layout.tsx
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { MatchProvider } from './MatchContext'
|
||||||
|
import type { Match } from '@/app/types/match'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const revalidate = 0
|
||||||
|
// (optional) falls du sicher Node Runtime willst:
|
||||||
|
// export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
async function loadMatch(matchId: string): Promise<Match | null> {
|
||||||
|
const h = await headers(); // ⬅️ wichtig
|
||||||
|
const proto = (h.get('x-forwarded-proto') ?? 'http').split(',')[0].trim()
|
||||||
|
const host = (h.get('x-forwarded-host') ?? h.get('host') ?? '').split(',')[0].trim()
|
||||||
|
|
||||||
|
// Fallback, falls in seltenen Fällen kein Host vorhanden ist (z. B. bei lokalen Tests)
|
||||||
|
const base = host ? `${proto}://${host}` : (process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000')
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/api/matches/${matchId}`, { cache: 'no-store' })
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MatchLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
params: { matchId: string } | Promise<{ matchId: string }>
|
||||||
|
}) {
|
||||||
|
// In neueren Next-Versionen können params ein Promise sein:
|
||||||
|
const { matchId } = await Promise.resolve(params as any)
|
||||||
|
|
||||||
|
const match = await loadMatch(matchId)
|
||||||
|
if (!match) return notFound()
|
||||||
|
|
||||||
|
return <MatchProvider match={match}>{children}</MatchProvider>
|
||||||
|
}
|
||||||
@ -1,20 +0,0 @@
|
|||||||
// /app/match-details/[matchId]/map-vote/page.tsx
|
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
import Card from '@/app/components/Card'
|
|
||||||
import MapVetoPanel from '@/app/components/MapVetoPanel'
|
|
||||||
|
|
||||||
async function loadMatch(id: string) {
|
|
||||||
const r = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/api/matches/${id}`, { cache: 'no-store' })
|
|
||||||
if (!r.ok) return null
|
|
||||||
return r.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function MapVetoPage({ params }: { params: { matchId: string } }) {
|
|
||||||
const match = await loadMatch(params.matchId)
|
|
||||||
if (!match) return notFound()
|
|
||||||
return (
|
|
||||||
<Card maxWidth="auto">
|
|
||||||
<MapVetoPanel match={match} />
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,28 +1,6 @@
|
|||||||
// /app/match-details/[matchId]/page.tsx
|
// app/match-details/[matchId]/page.tsx
|
||||||
import Card from '@/app/components/Card'
|
import MatchDetailsClient from './MatchDetailsClient'
|
||||||
import { MatchDetails } from '@/app/components/MatchDetails'
|
|
||||||
import type { Match } from '@/app/types/match'
|
|
||||||
|
|
||||||
interface PageProps {
|
export default function MatchDetailsPage() {
|
||||||
params: {
|
return <MatchDetailsClient />
|
||||||
matchId: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function MatchDetailsPage({ params }: PageProps) {
|
|
||||||
const res = await fetch(`http://localhost:3000/api/matches/${params.matchId}`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return <div className="p-8 text-red-600">Fehler beim Laden des Matches</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const match: Match = await res.json()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card maxWidth="auto">
|
|
||||||
<MatchDetails match={match} />
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/app/match-details/[matchId]/vote/VoteClient.tsx
Normal file
10
src/app/match-details/[matchId]/vote/VoteClient.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// app/match-details/[matchId]/vote/VoteClient.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import MapVetoPanel from '@/app/components/MapVetoPanel'
|
||||||
|
import { useMatch } from '../MatchContext' // aus dem Layout-Context
|
||||||
|
|
||||||
|
export default function VoteClient() {
|
||||||
|
const match = useMatch()
|
||||||
|
return <MapVetoPanel match={match} />
|
||||||
|
}
|
||||||
11
src/app/match-details/[matchId]/vote/page.tsx
Normal file
11
src/app/match-details/[matchId]/vote/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// app/match-details/[matchId]/vote/page.tsx
|
||||||
|
import Card from '@/app/components/Card'
|
||||||
|
import VoteClient from './VoteClient' // Client-Komponente
|
||||||
|
|
||||||
|
export default function VotePage() {
|
||||||
|
return (
|
||||||
|
<Card maxWidth="auto">
|
||||||
|
<VoteClient />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -10,9 +10,50 @@ import { updatePremierRanksForUser } from './updatePremierRanks';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht in demos/YYYY-MM-DD nach einer .dem (oder .dem.part), die zu matchId passt.
|
||||||
|
* Rückgabe: absoluter Pfad oder null.
|
||||||
|
*/
|
||||||
|
function findExistingDemoByMatchId(demosRoot: string, matchId: string): string | null {
|
||||||
|
// match730_<map>_<matchId>_<premier|competitive>.dem[.part]
|
||||||
|
const re = new RegExp(`^match\\d+_.+_${matchId}_(premier|competitive)\\.dem(\\.part)?$`, 'i');
|
||||||
|
|
||||||
|
if (!fs.existsSync(demosRoot)) return null;
|
||||||
|
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(demosRoot, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dirent of entries) {
|
||||||
|
if (!dirent.isDirectory()) continue;
|
||||||
|
if (dirent.name === 'temp') continue; // temp auslassen
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dirent.name)) continue; // nur YYYY-MM-DD
|
||||||
|
|
||||||
|
const dayDir = path.join(demosRoot, dirent.name);
|
||||||
|
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
files = fs.readdirSync(dayDir);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fname of files) {
|
||||||
|
if (!fname.endsWith('.dem') && !fname.endsWith('.dem.part')) continue;
|
||||||
|
if (re.test(fname)) {
|
||||||
|
return path.join(dayDir, fname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function startCS2MatchCron() {
|
export function startCS2MatchCron() {
|
||||||
log('🚀 CS2-CronJob Runner gestartet!');
|
log('🚀 CS2-CronJob Runner gestartet!');
|
||||||
const job = cron.schedule('* * * * * *', async () => {
|
const job = cron.schedule('* * * * * *', async () => {
|
||||||
@ -36,7 +77,7 @@ async function runMatchCheck() {
|
|||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
const decryptedAuthCode = decrypt(user.authCode!);
|
const decryptedAuthCode = decrypt(user.authCode!);
|
||||||
const allNewMatches = [];
|
const allNewMatches: { id: string }[] = [];
|
||||||
|
|
||||||
let latestKnownCode = user.lastKnownShareCode!;
|
let latestKnownCode = user.lastKnownShareCode!;
|
||||||
let nextShareCode = await getNextShareCodeFromAPI(user.steamId, decryptedAuthCode, latestKnownCode);
|
let nextShareCode = await getNextShareCodeFromAPI(user.steamId, decryptedAuthCode, latestKnownCode);
|
||||||
@ -92,7 +133,7 @@ async function runMatchCheck() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existingMatch) {
|
if (existingMatch) {
|
||||||
// log(`[${user.steamId}] ↪️ Match ${matchInfo.matchId} existiert bereits – übersprungen`);
|
// Match ist bereits in der DB – überspringen
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { steamId: user.steamId },
|
where: { steamId: user.steamId },
|
||||||
data: { lastKnownShareCode: nextShareCode },
|
data: { lastKnownShareCode: nextShareCode },
|
||||||
@ -120,11 +161,17 @@ async function runMatchCheck() {
|
|||||||
|
|
||||||
const shareCode = encodeMatch(matchInfo);
|
const shareCode = encodeMatch(matchInfo);
|
||||||
|
|
||||||
const expectedFilename = `${matchInfo.matchId}.dem`;
|
// ⬇️ NEU: im demos-Ordner (YYYY-MM-DD Unterordner) nach existierender Demo suchen
|
||||||
const expectedFilePath = path.join(process.cwd(), 'demos', expectedFilename);
|
const demosRoot = path.join(process.cwd(), 'demos');
|
||||||
|
const existingDemoPath = findExistingDemoByMatchId(demosRoot, matchInfo.matchId.toString());
|
||||||
|
|
||||||
if (fs.existsSync(expectedFilePath)) {
|
if (existingDemoPath) {
|
||||||
log(`[${user.steamId}] 📁 Match ${matchInfo.matchId} wurde bereits als Datei gespeichert – übersprungen`);
|
const rel = path.relative(demosRoot, existingDemoPath);
|
||||||
|
log(`[${user.steamId}] 📁 Match ${matchInfo.matchId} bereits vorhanden: ${rel} – übersprungen`);
|
||||||
|
|
||||||
|
// Hinweis: Wir überspringen den Download; DB-Eintrag existierte oben noch nicht.
|
||||||
|
// Beim nächsten Nutzer/Run wird das Match normal erfasst, oder du ergänzt später
|
||||||
|
// ein "parseExistingDemo(existingDemoPath)" falls gewünscht.
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { steamId: user.steamId },
|
where: { steamId: user.steamId },
|
||||||
@ -136,6 +183,7 @@ async function runMatchCheck() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kein File vorhanden -> Downloader/Parser anstoßen
|
||||||
const result = await runDownloaderForUser({
|
const result = await runDownloaderForUser({
|
||||||
...user,
|
...user,
|
||||||
lastKnownShareCode: shareCode,
|
lastKnownShareCode: shareCode,
|
||||||
|
|||||||
@ -1,146 +1,71 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
import type { Match, User } from '@/generated/prisma'
|
import { Match, User } from '@/generated/prisma';
|
||||||
import { parseAndStoreDemo } from './parseAndStoreDemo'
|
import { parseAndStoreDemo } from './parseAndStoreDemo';
|
||||||
import { log } from '../../scripts/cs2-cron-runner.js'
|
import { log } from '../../scripts/cs2-cron-runner.js';
|
||||||
import { prisma } from '../app/lib/prisma.js'
|
import { prisma } from '../app/lib/prisma.js';
|
||||||
|
|
||||||
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<{
|
export async function runDownloaderForUser(user: User): Promise<{
|
||||||
newMatches: Match[]
|
newMatches: Match[];
|
||||||
latestShareCode: string | null
|
latestShareCode: string | null;
|
||||||
}> {
|
}> {
|
||||||
if (!user.authCode || !user.lastKnownShareCode) {
|
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 steamId = user.steamId;
|
||||||
const shareCode = user.lastKnownShareCode
|
const shareCode = user.lastKnownShareCode;
|
||||||
|
|
||||||
log(`[${steamId}] 📥 Lade Demo herunter...`)
|
log(`[${user.steamId}] 📥 Lade Demo herunter...`);
|
||||||
|
|
||||||
// ───────────────────────── HTTP-Aufruf an Downloader ─────────────────────────
|
// 🎯 Nur HTTP-Modus
|
||||||
let data: DownloadResponse
|
|
||||||
try {
|
|
||||||
const res = await fetch('http://localhost:4000/download', {
|
const res = await fetch('http://localhost:4000/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ steamId, shareCode }),
|
body: JSON.stringify({ steamId, shareCode }),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
const data = await res.json();
|
||||||
const text = await res.text().catch(() => '')
|
|
||||||
log(`[${steamId}] ❌ Downloader HTTP ${res.status}: ${text || res.statusText}`, 'error')
|
if (!data.success) {
|
||||||
return { newMatches: [], latestShareCode: shareCode }
|
log(`[${steamId}] ❌ Downloader-Fehler: ${data.error}`, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
data = (await res.json()) as DownloadResponse
|
const demoPath = data.path;
|
||||||
} catch (err: any) {
|
|
||||||
log(`[${steamId}] ❌ Downloader-Netzwerkfehler: ${err?.message ?? String(err)}`, 'error')
|
|
||||||
return { newMatches: [], latestShareCode: shareCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data?.success) {
|
|
||||||
log(`[${steamId}] ❌ Downloader-Fehler: ${data?.error ?? 'unbekannt'}`, 'error')
|
|
||||||
return { newMatches: [], latestShareCode: shareCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
let demoPath: string | undefined = data.path
|
|
||||||
const matchIdFromResp: string | undefined = data.matchId ?? undefined
|
|
||||||
|
|
||||||
if (!demoPath) {
|
if (!demoPath) {
|
||||||
log(`[${steamId}] ⚠️ Kein Demo-Pfad erhalten – Match wird übersprungen`, 'warn')
|
log(`[${steamId}] ⚠️ Kein Demo-Pfad erhalten – Match wird übersprungen`, 'warn');
|
||||||
return { newMatches: [], latestShareCode: shareCode }
|
return { newMatches: [], latestShareCode: shareCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── Pfad plattformneutral absolut machen ─────────────
|
const filename = path.basename(demoPath);
|
||||||
let absolutePath = (isWinAbs(demoPath) || isUnixAbs(demoPath))
|
const matchId = filename.replace(/\.dem$/, '');
|
||||||
? demoPath
|
|
||||||
: path.resolve(process.cwd(), demoPath) // falls relativ geliefert
|
|
||||||
|
|
||||||
// ───────────────────────── Existenz prüfen; ggf. Fallback mit matchId ───────
|
const existing = await prisma.match.findUnique({
|
||||||
try {
|
where: { id: matchId },
|
||||||
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) ?? ''
|
|
||||||
|
|
||||||
try {
|
if (existing) {
|
||||||
const entries = await fs.readdir(dir)
|
log(`[${steamId}] 🔁 Match ${matchId} wurde bereits analysiert – übersprungen`, 'info');
|
||||||
const hit = entries.find(n =>
|
return { newMatches: [], latestShareCode: shareCode };
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────────── matchId bestimmen (DB-Duplikat-Check) ────────────
|
log(`[${steamId}] 📂 Analysiere: ${filename}`);
|
||||||
const matchId =
|
|
||||||
matchIdFromResp ??
|
|
||||||
extractMatchIdFromName(demoPath.split(/[/\\]/).pop() ?? '') ??
|
|
||||||
''
|
|
||||||
|
|
||||||
if (!matchId) {
|
const absolutePath = path.resolve(__dirname, '../../../cs2-demo-downloader', demoPath);
|
||||||
log(`[${steamId}] ⚠️ Konnte matchId nicht ermitteln – übersprungen`, 'warn')
|
const match = await parseAndStoreDemo(absolutePath, steamId, shareCode);
|
||||||
return { newMatches: [], latestShareCode: shareCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
const existsInDb = await prisma.match.findUnique({ where: { id: matchId } })
|
const newMatches: Match[] = [];
|
||||||
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) {
|
if (match) {
|
||||||
newMatches.push(match)
|
newMatches.push(match);
|
||||||
log(`[${steamId}] ✅ Match gespeichert: ${match.id}`)
|
log(`[${steamId}] ✅ Match gespeichert: ${match.id}`);
|
||||||
} else {
|
} else {
|
||||||
log(`[${steamId}] ⚠️ Match bereits vorhanden oder Analyse fehlgeschlagen`, 'warn')
|
log(`[${steamId}] ⚠️ Match bereits vorhanden oder Analyse fehlgeschlagen`, 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newMatches,
|
newMatches,
|
||||||
latestShareCode: shareCode,
|
latestShareCode: shareCode,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user