This commit is contained in:
Linrador 2025-08-14 15:06:48 +02:00
parent 61c75b1c8c
commit 6caf57d282
32 changed files with 2020 additions and 1057 deletions

View File

@ -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 })
}
}

View 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,
},
}
}

View File

@ -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)
} }

View 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 })
}

View File

@ -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 } = {}

View 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 })
}
}

View 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 })
}
}

View File

@ -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 })
}
}

View 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>
)
}

View File

@ -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>

View File

@ -1,3 +1,4 @@
// Dropdown.tsx
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
export type DropdownItem = { export type DropdownItem = {

View File

@ -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>

View 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>
)
}

View File

@ -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: '' } ...(data.activePlayers ?? []),
: x
const raw = [
...(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])
/* ---- DragnDrop-Handler -------------------------------- */ /* ---- DragnDrop-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}
> >

View File

@ -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 ' +

View File

@ -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()
@ -41,32 +43,29 @@ export default function MapVetoPanel({ match }: Props) {
const isOpenFromMatch = nowTs >= opensAtTs const isOpenFromMatch = nowTs >= opensAtTs
// --- 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); if (!teamId) return false
if (isAdmin) return true
const canActForTeamId = useCallback((teamId?: string | null) => { return (
if (!teamId) return false (teamId === match.teamA?.id && isLeaderA) ||
if (isAdmin) return true (teamId === match.teamB?.id && isLeaderB)
return (teamId === match.teamA?.id && isLeaderA) || )
(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,33 +97,36 @@ 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(
const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch () => (state?.opensAt ? new Date(state.opensAt).getTime() : null),
[state?.opensAt],
)
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 ?? []
// 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,50 +169,73 @@ export default function MapVetoPanel({ match }: Props) {
submittedRef.current = false submittedRef.current = false
}, []) }, [])
const finishAndSubmit = useCallback((map: string) => { const finishAndSubmit = useCallback(
if (submittedRef.current) return (map: string) => {
submittedRef.current = true if (submittedRef.current) return
setTimeout(() => handlePickOrBan(map), 10) submittedRef.current = true
}, [handlePickOrBan]) setTimeout(() => handlePickOrBan(map), 10)
},
[handlePickOrBan],
)
const stepHold = useCallback((ts: number) => { const stepHold = useCallback(
if (!holdStartRef.current || !holdMapRef.current) return (ts: number) => {
const elapsed = ts - holdStartRef.current if (!holdStartRef.current || !holdMapRef.current) return
const p = Math.min(1, elapsed / HOLD_MS) const elapsed = ts - holdStartRef.current
const map = holdMapRef.current const p = Math.min(1, elapsed / HOLD_MS)
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
resetHold()
finishAndSubmit(doneMap)
return
}
rafRef.current = requestAnimationFrame(stepHold)
},
[resetHold, finishAndSubmit],
)
const onHoldStart = useCallback(
(map: string, allowed: boolean) => {
if (!allowed) return
resetHold() resetHold()
finishAndSubmit(doneMap) holdMapRef.current = map
return holdStartRef.current = performance.now()
} setProgressByMap((prev) => ({ ...prev, [map]: 0 }))
rafRef.current = requestAnimationFrame(stepHold) rafRef.current = requestAnimationFrame(stepHold)
}, [resetHold, finishAndSubmit]) },
[stepHold, resetHold],
)
const onHoldStart = useCallback((map: string, allowed: boolean) => { const cancelOrSubmitIfComplete = useCallback(
if (!allowed) return (map: string) => {
resetHold() const p = progressByMap[map] ?? 0
holdMapRef.current = map if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) {
holdStartRef.current = performance.now() resetHold()
setProgressByMap(prev => ({ ...prev, [map]: 0 })) finishAndSubmit(map)
rafRef.current = requestAnimationFrame(stepHold) return
}, [stepHold, resetHold]) }
if (holdMapRef.current === map) {
resetHold()
setProgressByMap((prev) => ({ ...prev, [map]: 0 }))
}
},
[progressByMap, resetHold, finishAndSubmit],
)
const cancelOrSubmitIfComplete = useCallback((map: string) => { const deciderChooserTeamId = useMemo(() => {
const p = progressByMap[map] ?? 0 const steps = state?.steps ?? []
if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) { const decIdx = steps.findIndex(s => s.action === 'decider')
resetHold() if (decIdx < 0) return null
finishAndSubmit(map) for (let i = decIdx - 1; i >= 0; i--) {
return const s = steps[i]
if (s.action === 'ban' && s.teamId) return s.teamId
} }
if (holdMapRef.current === map) { return null
resetHold() }, [state?.steps])
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
}
}, [progressByMap, resetHold, finishAndSubmit])
// 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,206 +247,365 @@ 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">
{/* Header */} {showLoading ? (
<div className="flex items-center justify-between mb-3"> <div className="p-4">Lade Map-Voting</div>
<h3 className="text-lg font-semibold">Map-Vote</h3> ) : showError ? (
<div className="text-sm opacity-80">Modus: BO{match.bestOf ?? state?.bestOf ?? 3}</div> <div className="p-4 text-red-600">{error}</div>
</div> ) : (
<>
{/* Countdown / Status */} {/* Header */}
{!isOpen && ( <div className="flex items-center justify-between mb-3">
<div className="mb-4 text-sm"> <h3 className="text-lg font-semibold">Map-Vote</h3>
<span className="inline-block px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"> <div className="flex items-center gap-2">
Öffnet in {formatCountdown(msToOpen)} <div className="text-sm opacity-80">
</span> Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
</div> </div>
)} {isAdmin && (
<Button
{/* Hauptbereich */} color="red"
{state && ( variant="outline"
<div className="mt-2 flex items-start gap-4 justify-between"> size="sm"
{/* Links Team A */} className="ml-3"
<aside className="hidden lg:flex lg:flex-col gap-2 w-56"> title="Map-Vote zurücksetzen"
{playersA.map((p: MatchPlayer) => ( onClick={async () => {
<MapVoteProfileCard if (!confirm('Map-Vote wirklich zurücksetzen? Alle bisherigen Picks/Bans gehen verloren.')) return
key={p.user.steamId} try {
side="A" const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' })
name={p.user.name ?? 'Unbekannt'} if (!r.ok) {
avatar={p.user.avatar} const j = await r.json().catch(() => ({}))
rank={p.stats?.rankNew ?? 0} alert(j.message ?? 'Reset fehlgeschlagen')
matchType={match.matchType} return
isLeader={(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>
{/* Mitte Maps untereinander (kompakt + Hold-to-confirm) */}
<main className="max-w-sm flex-shrink-0">
<ul className="flex flex-col gap-1.5">
{mapPool.map((map) => {
const decision = decisionByMap.get(map)
const status = decision?.action ?? null // 'ban' | 'pick' | 'decider' | null
const teamId = decision?.teamId ?? null
const taken = !!status
const isAvailable = !taken && isMyTurn && isOpen && !state?.locked
const baseClasses =
'relative flex items-center justify-between gap-2 rounded-md border p-2.5 transition select-none'
const visualClasses =
taken
? (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'
: status === 'pick' || status === 'decider'
? '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')
: (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 pickedByB = status === 'pick' && teamId === match.teamB?.id
const showLeftLogo = pickedByA
const showRightLogo = pickedByB
const leftLogo = getTeamLogo(match.teamA?.logo)
const rightLogo = getTeamLogo(match.teamB?.logo)
const progress = progressByMap[map] ?? 0
const showProgress = isAvailable && progress > 0 && progress < 1
return (
<li key={map}>
<button
type="button"
className={`${baseClasses} ${visualClasses} w-full text-left`}
disabled={!isAvailable}
title={
taken
? status === 'ban'
? 'Map gebannt'
: status === 'pick'
? 'Map gepickt'
: 'Decider'
: isAvailable
? 'Zum Bestätigen gedrückt halten'
: 'Nur der Team-Leader (oder Admin) darf wählen'
} }
onMouseDown={() => onHoldStart(map, isAvailable)} // SSE feuert ohnehin; zusätzlich lokal nachladen:
onMouseUp={() => cancelOrSubmitIfComplete(map)} await load()
onMouseLeave={() => cancelOrSubmitIfComplete(map)} } catch {
onTouchStart={onTouchStart(map, isAvailable)} alert('Netzwerkfehler beim Reset')
onTouchEnd={onTouchEnd(map)} }
onTouchCancel={onTouchEnd(map)} }}
> />
{/* Fortschrittsbalken (unter dem Inhalt) */}
{showProgress && (
<span
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"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
)}
{/* Linkes Logo bei Pick durch Team A */}
{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">
<span className="text-[13px] font-medium truncate">
{fmt(map)}
</span>
{/* rotes X bei Ban über dem Namen */}
{status === 'ban' && (
<span
aria-hidden
className="absolute inset-0 pointer-events-none flex items-center justify-center z-[2]"
>
<svg
viewBox="0 0 24 24"
className="w-8 h-8 opacity-30 text-red-600"
fill="currentColor"
>
<path d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12l-4.9 4.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.9a1 1 0 0 0 1.41-1.41L13.41 12l4.9-4.89a1 1 0 0 0-.01-1.4Z" />
</svg>
</span>
)}
</div>
{/* Rechtes Logo bei Pick durch Team B */}
{showRightLogo && (
<img
src={rightLogo}
alt={match.teamB?.name ?? 'Team B'}
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900 z-[1]"
/>
)}
</button>
</li>
)
})}
</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&nbsp;
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
&nbsp;(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> </div>
</main> </div>
{/* Rechts Team B */} {/* Countdown / Status */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56"> {!isOpen && (
{playersB.map((p: MatchPlayer) => ( <div className="mb-4 text-sm">
<MapVoteProfileCard <span className="inline-block px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100">
key={p.user.steamId} Öffnet in {formatCountdown(msToOpen)}
side="B" </span>
name={p.user.name ?? 'Unbekannt'} </div>
avatar={p.user.avatar} )}
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType} {/* Countdown / Status ganz oben und größer */}
isLeader={(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === p.user.steamId} <div className="mb-4 flex justify-center">
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) && !state.locked} {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
</aside> </span>
</div> ) : 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&nbsp;
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
&nbsp;(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 */}
{state && (
<div className="mt-2 flex items-start gap-4 justify-between">
{/* Links Team A */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56">
{playersA.map((p: MatchPlayer) => (
<MapVoteProfileCard
key={p.user.steamId}
side="A"
name={p.user.name ?? 'Unbekannt'}
avatar={p.user.avatar}
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType}
isLeader={
(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>
{/* Mitte Maps (Hold-to-confirm) */}
<main className="max-w-sm flex-shrink-0">
<ul className="flex flex-col gap-1.5">
{mapPool.map((map) => {
const decision = decisionByMap.get(map)
const status = decision?.action ?? null // 'ban' | 'pick' | 'decider' | null
const teamId = decision?.teamId ?? null
const taken = !!status
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 =
'relative flex items-center justify-between gap-2 rounded-md border p-2.5 transition select-none'
const visualTaken =
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'
: status === 'pick' || status === 'decider'
? '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'
const visualAvailable = `bg-white dark:bg-neutral-900 ${intentStyles.border} ring-1 ${intentStyles.ring} ${intentStyles.hover} cursor-pointer`
const visualDisabled = 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700'
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
// Decider-Team bestimmen (falls nötig)
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 showProgress = isAvailable && progress > 0 && progress < 1
return (
<li
key={map}
className="grid grid-cols-[24px_1fr_24px] items-center gap-2"
>
{/* 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}
title={
taken
? status === 'ban'
? 'Map gebannt'
: status === 'pick'
? 'Map gepickt'
: 'Decider'
: isAvailable
? 'Zum Bestätigen gedrückt halten'
: 'Nur der Team-Leader (oder Admin) darf wählen'
}
onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)}
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
onTouchStart={onTouchStart(map, isAvailable)}
onTouchEnd={onTouchEnd(map)}
onTouchCancel={onTouchEnd(map)}
>
{/* Fortschrittsbalken */}
{showProgress && (
<span
aria-hidden
className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-0`}
style={{ width: `${Math.round(progress * 100)}%` }}
/>
)}
{/* Inhalt */}
<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">{fmt(map)}</span>
{status === 'ban' && (
<span
aria-hidden
className="absolute inset-0 pointer-events-none flex items-center justify-center z-[2]"
>
<svg
viewBox="0 0 24 24"
className="w-8 h-8 opacity-30 text-red-600"
fill="currentColor"
>
<path d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12l-4.9 4.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.9a1 1 0 0 0 1.41-1.41L13.41 12l4.9-4.89a1 1 0 0 0-.01-1.4Z" />
</svg>
</span>
)}
</div>
</Button>
{/* rechter Slot */}
{pickedByB ? (
<img
src={getTeamLogo(match.teamB?.logo)}
alt={match.teamB?.name ?? 'Team B'}
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900"
/>
) : (
<div className="w-6 h-6" />
)}
</li>
)
})}
</ul>
</main>
{/* Rechts Team B */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56">
{playersB.map((p: MatchPlayer) => (
<MapVoteProfileCard
key={p.user.steamId}
side="B"
name={p.user.name ?? 'Unbekannt'}
avatar={p.user.avatar}
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType}
isLeader={
(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>
</div>
)}
</>
)} )}
</div> </div>
) )
@ -419,6 +617,6 @@ function formatCountdown(ms: number) {
const h = Math.floor(totalSec / 3600) const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60) const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60 const s = totalSec % 60
const pad = (n:number)=>String(n).padStart(2,'0') const pad = (n: number) => String(n).padStart(2, '0')
return `${h}:${pad(m)}:${pad(s)}` return `${h}:${pad(m)}:${pad(s)}`
} }

View File

@ -1,36 +1,42 @@
/* /*
/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'
import { de } from 'date-fns/locale' 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,49 +44,84 @@ 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
const userId = me?.steamId const userId = me?.steamId
const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId
const isLeaderB = !!userId && userId === match.teamB?.leader?.steamId const isLeaderB = !!userId && userId === match.teamB?.leader?.steamId
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) => const mapKey = normalizeMapKey(match.map)
(raw ?? '') const mapLabel =
.toLowerCase() MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
.replace(/\.bsp$/,'') MAP_OPTIONS.find(opt => opt.key === 'lobby_mapveto')?.label ??
.replace(/^.*\//,'') 'Unbekannte Map'
const mapKey = normalizeMapKey(match.map)
const mapLabel = mapNameMap[mapKey]?.name ?? (match.map ?? '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,82 +137,85 @@ 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"
<Table.Cell className="py-1 flex items-center gap-2" hoverable onClick={() => router.push(`/profile/${p.user.steamId}`)} > 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 ? '+' : ''} p.stats.rankChange > 0
{p.stats.rankChange} ? 'text-green-500'
</span> : p.stats.rankChange < 0
)} ? 'text-red-500'
: ''
}`}
>
{p.stats.rankChange > 0 ? '+' : ''}
{p.stats.rankChange}
</span>
)}
</div> </div>
</Table.Cell> </Table.Cell>
@ -180,14 +224,14 @@ export function MatchDetails ({ match }: { match: Match }) {
? `${Number(p.stats?.aim).toFixed(0)} %` ? `${Number(p.stats?.aim).toFixed(0)} %`
: '-'} : '-'}
</Table.Cell> </Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.oneK ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.oneK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.twoK ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.twoK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.threeK ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.threeK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fourK ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.fourK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fiveK ?? '-'}</Table.Cell> <Table.Cell>{p.stats?.fiveK ?? '-'}</Table.Cell>
<Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell> <Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell> <Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell> <Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>
@ -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>
Match löschen <Button onClick={handleDelete} className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md">
</Button> Match löschen
</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 && (
<Button <Alert type="soft" color="info" className="flex items-center justify-between gap-4">
size='sm' <span>
onClick={() => setEditSide('A')} Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
className="px-3 py-1.5 text-sm rounded-lg </span>
bg-blue-600 hover:bg-blue-700 text-white" <Button
> size="sm"
Spieler bearbeiten onClick={() => setEditSide('A')}
</Button> className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
>
Spieler bearbeiten
</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 && (
<Button <Alert type="soft" color="info" className="flex items-center justify-between gap-4">
size='sm' <span>
onClick={() => setEditSide('B')} Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
className="px-3 py-1.5 text-sm rounded-lg </span>
bg-blue-600 hover:bg-blue-700 text-white" <Button
> size="sm"
Spieler bearbeiten onClick={() => setEditSide('B')}
</Button> className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
>
Spieler bearbeiten
</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>

View File

@ -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 [coords, setCoords] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 });
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(null); // 👈 NEU
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 spaceAbove = rect.top;
const dir: "up" | "down" =
dropDirection === "auto"
? spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? "up" : "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)
});
};
useEffect(() => { useEffect(() => {
if (open && dropDirection === "auto" && buttonRef.current) { if (!open) return;
requestAnimationFrame(() => { computePosition();
const rect = buttonRef.current!.getBoundingClientRect();
const dropdownHeight = 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) { const onScroll = () => computePosition();
setDirection("up"); const onResize = () => computePosition();
} else { window.addEventListener("scroll", onScroll, true);
setDirection("down"); 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);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, dropDirection]); }, [open, dropDirection]);
// Click-outside: ignoriert Klicks im Portal-Menü
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handlePointerDown = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) { const t = event.target as Node;
setOpen(false); const clickedInsideRoot = !!rootRef.current?.contains(t);
} const clickedInsideMenu = !!menuRef.current?.contains(t);
if (!clickedInsideRoot && !clickedInsideMenu) setOpen(false);
}; };
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handleClickOutside); 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>
); );
} }

View File

@ -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>

View File

@ -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
View 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' },
]

View File

@ -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',

View 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>
}

View 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} />
}

View 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>
}

View File

@ -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>
)
}

View File

@ -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>
)
} }

View 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} />
}

View 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>
)
}

View File

@ -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,

View File

@ -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 const res = await fetch('http://localhost:4000/download', {
try { method: 'POST',
const res = await fetch('http://localhost:4000/download', { headers: { 'Content-Type': 'application/json' },
method : 'POST', body: JSON.stringify({ steamId, shareCode }),
headers: { 'Content-Type': 'application/json' }, });
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')
return { newMatches: [], latestShareCode: shareCode }
}
data = (await res.json()) as DownloadResponse if (!data.success) {
} catch (err: any) { log(`[${steamId}] ❌ Downloader-Fehler: ${data.error}`, 'error');
log(`[${steamId}] ❌ Downloader-Netzwerkfehler: ${err?.message ?? String(err)}`, 'error')
return { newMatches: [], latestShareCode: shareCode }
} }
if (!data?.success) { const demoPath = data.path;
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,
} };
} }