diff --git a/src/app/api/matches/[id]/route.ts b/src/app/api/matches/[id]/route.ts deleted file mode 100644 index 07527db..0000000 --- a/src/app/api/matches/[id]/route.ts +++ /dev/null @@ -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 }) - } -} diff --git a/src/app/api/matches/[matchId]/_builders.ts b/src/app/api/matches/[matchId]/_builders.ts new file mode 100644 index 0000000..4173b06 --- /dev/null +++ b/src/app/api/matches/[matchId]/_builders.ts @@ -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, + }, + } +} diff --git a/src/app/api/matches/[id]/delete/route.ts b/src/app/api/matches/[matchId]/delete/route.ts similarity index 88% rename from src/app/api/matches/[id]/delete/route.ts rename to src/app/api/matches/[matchId]/delete/route.ts index 5bc2ff4..cdbf5a6 100644 --- a/src/app/api/matches/[id]/delete/route.ts +++ b/src/app/api/matches/[matchId]/delete/route.ts @@ -1,11 +1,10 @@ -// /app/api/matches/[id]/delete/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 { 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 { const session = await getServerSession(authOptions(req)) 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 }) } - const matchId = params?.id + const matchId = params?.matchId if (!matchId) { 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 }) } - // Alles in einer Transaktion lΓΆschen await prisma.$transaction(async (tx) => { await tx.mapVetoStep.deleteMany({ where: { veto: { 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 } }) }) - // πŸ”” Realtime-Broadcasts (flat payload) try { await sendServerSSEMessage({ type: 'match-deleted', matchId }) await sendServerSSEMessage({ type: 'matches-updated' }) } catch (e) { - // Broadcast-Fehler sollen das LΓΆschen nicht rΓΌckgΓ€ngig machen console.error('[DELETE MATCH] SSE broadcast failed', e) } diff --git a/src/app/api/matches/[matchId]/mapvote/reset/route.ts b/src/app/api/matches/[matchId]/mapvote/reset/route.ts new file mode 100644 index 0000000..61fb4ab --- /dev/null +++ b/src/app/api/matches/[matchId]/mapvote/reset/route.ts @@ -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 }) +} diff --git a/src/app/api/matches/[id]/map-vote/route.ts b/src/app/api/matches/[matchId]/mapvote/route.ts similarity index 98% rename from src/app/api/matches/[id]/map-vote/route.ts rename to src/app/api/matches/[matchId]/mapvote/route.ts index a0f1991..042ac17 100644 --- a/src/app/api/matches/[id]/map-vote/route.ts +++ b/src/app/api/matches/[matchId]/mapvote/route.ts @@ -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 { getServerSession } from 'next-auth' import { authOptions } from '@/app/lib/auth' @@ -260,9 +260,9 @@ async function buildTeamsPayload(match: any, req: NextRequest) { /* -------------------- GET -------------------- */ -export async function GET(req: NextRequest, { params }: { params: { id: string } }) { +export async function GET(req: NextRequest, { params }: { params: { matchId: string } }) { try { - const matchId = params.id + const matchId = params.matchId if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 }) const { match, veto } = await ensureVeto(matchId) @@ -282,12 +282,12 @@ export async function GET(req: NextRequest, { params }: { params: { id: string } /* -------------------- 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 me = session?.user as { steamId: string; isAdmin?: boolean } | undefined 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 }) let body: { map?: string } = {} diff --git a/src/app/api/matches/[matchId]/meta/route.ts b/src/app/api/matches/[matchId]/meta/route.ts new file mode 100644 index 0000000..78c7e69 --- /dev/null +++ b/src/app/api/matches/[matchId]/meta/route.ts @@ -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 }) + } +} \ No newline at end of file diff --git a/src/app/api/matches/[matchId]/route.ts b/src/app/api/matches/[matchId]/route.ts new file mode 100644 index 0000000..5b80cc5 --- /dev/null +++ b/src/app/api/matches/[matchId]/route.ts @@ -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() + 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 }) + } +} diff --git a/src/app/api/team/route.ts b/src/app/api/team/route.ts deleted file mode 100644 index 7a87bc4..0000000 --- a/src/app/api/team/route.ts +++ /dev/null @@ -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 }) - } -} diff --git a/src/app/api/team/list/route.ts b/src/app/api/teams/route.ts similarity index 100% rename from src/app/api/team/list/route.ts rename to src/app/api/teams/route.ts diff --git a/src/app/components/Alert.tsx b/src/app/components/Alert.tsx new file mode 100644 index 0000000..52dac2c --- /dev/null +++ b/src/app/components/Alert.tsx @@ -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> = { + 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 ( +
+ {children} +
+ ) +} diff --git a/src/app/components/Button.tsx b/src/app/components/Button.tsx index 2216bb9..deba9ee 100644 --- a/src/app/components/Button.tsx +++ b/src/app/components/Button.tsx @@ -1,6 +1,6 @@ 'use client' -import { ReactNode, forwardRef, useState, useRef, useEffect } from 'react' +import { ReactNode, forwardRef, useState, useRef, useEffect, ButtonHTMLAttributes } from 'react' type ButtonProps = { title?: string @@ -14,7 +14,7 @@ type ButtonProps = { className?: string dropDirection?: "up" | "down" | "auto" disabled?: boolean -} +} & ButtonHTMLAttributes const Button = forwardRef(function Button( { @@ -28,7 +28,8 @@ const Button = forwardRef(function Button( size = 'md', className, dropDirection = "down", - disabled = false + disabled = false, + ...rest }, ref ) { @@ -147,6 +148,7 @@ const Button = forwardRef(function Button( className={classes} onClick={toggle} {...modalAttributes} + {...rest} > {children ?? title} diff --git a/src/app/components/Dropdown.tsx b/src/app/components/Dropdown.tsx index b20ae05..8f1c100 100644 --- a/src/app/components/Dropdown.tsx +++ b/src/app/components/Dropdown.tsx @@ -1,3 +1,4 @@ +// Dropdown.tsx import { useEffect, useRef, useState } from 'react' export type DropdownItem = { diff --git a/src/app/components/DroppableZone.tsx b/src/app/components/DroppableZone.tsx index 0a52418..8c716f2 100644 --- a/src/app/components/DroppableZone.tsx +++ b/src/app/components/DroppableZone.tsx @@ -10,6 +10,7 @@ type DroppableZoneProps = { children: React.ReactNode activeDragItem: Player | null saveSuccess?: boolean + className?: string } export function DroppableZone({ @@ -17,6 +18,7 @@ export function DroppableZone({ label, children, saveSuccess = false, + className, }: DroppableZoneProps) { const { isOver, setNodeRef } = useDroppable({ id }) const { over } = useDndContext() @@ -33,7 +35,8 @@ export function DroppableZone({ 'w-full rounded-lg p-4 transition-colors min-h-[200px]', isOverZone ? '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 ( @@ -64,7 +67,7 @@ export function DroppableZone({ {/* Hier sitzt der Droppable-Ref */}
-
+
{children}
diff --git a/src/app/components/EditMatchMetaModal.tsx b/src/app/components/EditMatchMetaModal.tsx new file mode 100644 index 0000000..4a07143 --- /dev/null +++ b/src/app/components/EditMatchMetaModal.tsx @@ -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(defaultTeamAId ?? '') + const [teamBId, setTeamBId] = useState(defaultTeamBId ?? '') + const [date, setDate] = useState(() => { + 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(defaultMap ?? 'lobby_mapveto') + const [vetoLead, setVetoLead] = useState(defaultVetoLeadMinutes) + + const [teams, setTeams] = useState([]) + const [loadingTeams, setLoadingTeams] = useState(false) + + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(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 ( + + {error && ( + + {error} + + )} + +
+ {/* Titel */} +
+ + setTitle(e.target.value)} + placeholder="z.B. Scrim vs. XYZ" + /> +
+ + {/* Team A */} +
+ + +
+ + {/* Datum/Uhrzeit */} +
+ + setDate(e.target.value)} + /> +

+ Wird als ISO gespeichert ({date ? new Date(date).toISOString() : 'β€”'}). +

+
+ + {/* Map */} +
+ + setVetoLead(Number(e.target.value))} + /> +

+ Zeit vor Matchstart, zu der das Veto ΓΆffnet (Standard 60). +

+
+
+
+ ) +} diff --git a/src/app/components/EditMatchPlayersModal.tsx b/src/app/components/EditMatchPlayersModal.tsx index cfaef87..fe0da5e 100644 --- a/src/app/components/EditMatchPlayersModal.tsx +++ b/src/app/components/EditMatchPlayersModal.tsx @@ -72,54 +72,53 @@ export default function EditMatchPlayersModal (props: Props) { /* ---- Komplett-Spielerliste laden ------------------------ */ useEffect(() => { 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) setError(null) ;(async () => { try { - const res = await fetch(`/api/team/${team.id}`) + const res = await fetch(`/api/team/${encodeURIComponent(team.id)}`, { + cache: 'no-store', + }) if (!res.ok) { setError(`Team-API: ${res.status}`) - setPlayers([]) // leer, aber gleich nicht mehr "loading" + setPlayers([]) return } const data = await res.json() - // πŸ”§ Normalizer: akzeptiert string | Player - const toPlayer = (x: any): Player => - typeof x === 'string' - ? { steamId: x, name: 'Unbekannt', avatar: '' } - : x - - const raw = [ - ...(data.activePlayers ?? []), + // πŸ‘‰ Hier brauchst du KEIN Normalizer mehr, wenn deine /api/team-Route + // (wie zuletzt angepasst) bereits Player-Objekte liefert. + const all = [ + ...(data.activePlayers ?? []), ...(data.inactivePlayers ?? []), ] - - // πŸ”§ Dedupe robust - const byId = new Map() - 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 || '')) + .filter((p: Player) => !!p?.steamId) + .filter((p: Player, i: number, arr: Player[]) => arr.findIndex(x => x.steamId === p.steamId) === i) + .sort((a: Player, b: Player) => (a.name || '').localeCompare(b.name || '')) setPlayers(all) - setSelected(myInit) // initiale Auswahl ΓΌbernehmen + setSelected(myInit) // initiale Auswahl aus Props setSaved(false) - } catch (e: any) { + } catch (e) { console.error('[EditMatchPlayersModal] load error:', e) setError('Laden fehlgeschlagen') setPlayers([]) } finally { - setLoading(false) // βœ… nie in der Schleife hΓ€ngen bleiben + setLoading(false) } })() - }, [show, team?.id]) // ⚠️ myInit hier nicht nΓΆtig + }, [show, team?.id]) /* ---- Drag’n’Drop-Handler -------------------------------- */ const onDragStart = ({ active }: any) => { @@ -189,7 +188,7 @@ export default function EditMatchPlayersModal (props: Props) { onClose={onClose} onSave={handleSave} closeButtonTitle={ - saved ? 'βœ“ gespeichert' : saving ? 'Speichern …' : 'Speichern' + saved ? 'βœ“ Gespeichert' : saving ? 'Speichern …' : 'Speichern' } closeButtonColor={saved ? 'green' : 'blue'} disableSave={!canEdit || saving || !team?.id} @@ -222,6 +221,7 @@ export default function EditMatchPlayersModal (props: Props) { {/* --- Zone: Aktuell eingestellte Spieler ------------- */} diff --git a/src/app/components/MapVetoBanner.tsx b/src/app/components/MapVetoBanner.tsx index 9344915..1881ee0 100644 --- a/src/app/components/MapVetoBanner.tsx +++ b/src/app/components/MapVetoBanner.tsx @@ -1,36 +1,34 @@ +// MapVetoBanner.tsx 'use client' - import { useCallback, useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import { useSSEStore } from '@/app/lib/useSSEStore' -import type { Match } from '../types/match' import type { MapVetoState } from '../types/mapveto' -type Props = { - match: Match -} +type Props = { match: any; initialNow: number } -export default function MapVetoBanner({ match }: Props) { +export default function MapVetoBanner({ match, initialNow }: Props) { const router = useRouter() const { data: session } = useSession() const { lastEvent } = useSSEStore() + // βœ… eine Uhr, deterministisch bei Hydration (kommt als Prop vom Server) + const [now, setNow] = useState(initialNow) + const [state, setState] = useState(null) const [error, setError] = useState(null) const load = useCallback(async () => { try { 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) { const j = await r.json().catch(() => ({})) throw new Error(j?.message || 'Laden fehlgeschlagen') } const json = await r.json() - if (!json || !Array.isArray(json.steps)) { - throw new Error('UngΓΌltige Serverantwort (steps fehlt)') - } + if (!json || !Array.isArray(json.steps)) throw new Error('UngΓΌltige Serverantwort (steps fehlt)') setState(json) } catch (e: any) { setState(null) @@ -38,41 +36,39 @@ export default function MapVetoBanner({ match }: Props) { } }, [match.id]) + // βœ… tickt NUR im Client, nach Hydration + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, []) + useEffect(() => { load() }, [load]) // Live-Refresh via SSE useEffect(() => { - if (!lastEvent) return - if (lastEvent.type !== 'map-vote-updated') return - const mId = lastEvent.payload?.matchId - if (mId !== match.id) return + if (!lastEvent || lastEvent.type !== 'map-vote-updated') return + if (lastEvent.payload?.matchId !== match.id) return 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(() => { 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 - }, [state?.opensAt, match.matchDate, match.demoDate]) + }, [state?.opensAt, match.matchDate, match.demoDate, initialNow]) - const [nowTs, setNowTs] = useState(() => Date.now()) - useEffect(() => { - const t = setInterval(() => setNowTs(Date.now()), 1000) - return () => clearInterval(t) - }, []) - const isOpen = nowTs >= opensAt - const msToOpen = Math.max(opensAt - nowTs, 0) + const isOpen = now >= opensAt + const msToOpen = Math.max(opensAt - now, 0) - // Wer ist am Zug? const current = state?.steps?.[state.currentIndex] const whoIsUp = current?.teamId ? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name) : null - // Rechte nur fΓΌr Text - const isLeaderA = !!session?.user?.steamId && match.teamA?.leader === session.user.steamId - const isLeaderB = !!session?.user?.steamId && match.teamB?.leader === session.user.steamId + // ⚠️ leader ist bei dir ein Player-Objekt β†’ .steamId vergleichen + const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session.user.steamId + const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session.user.steamId const isAdmin = !!session?.user?.isAdmin const iCanAct = Boolean( isOpen && @@ -83,7 +79,7 @@ export default function MapVetoBanner({ match }: Props) { (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 = 'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' + diff --git a/src/app/components/MapVetoPanel.tsx b/src/app/components/MapVetoPanel.tsx index 35a83b9..89201ab 100644 --- a/src/app/components/MapVetoPanel.tsx +++ b/src/app/components/MapVetoPanel.tsx @@ -1,13 +1,15 @@ -// /app/components/MapVotePanel.tsx +// /app/components/MapVetoPanel.tsx 'use client' import { useEffect, useMemo, useState, useCallback, useRef } from 'react' +import type React from 'react' import { useSession } from 'next-auth/react' import { useSSEStore } from '@/app/lib/useSSEStore' -import { mapNameMap } from '../lib/mapNameMap' +import { MAP_OPTIONS } from '../lib/mapOptions' import MapVoteProfileCard from './MapVetoProfileCard' import type { Match, MatchPlayer } from '../types/match' import type { MapVetoState } from '../types/mapveto' +import Button from './Button' import { Player } from '../types/team' type Props = { match: Match } @@ -15,8 +17,8 @@ type Props = { match: Match } const getTeamLogo = (logo?: string | null) => logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp' -const HOLD_MS = 1200 // Dauer zum GedrΓΌckthalten (ms) -const COMPLETE_THRESHOLD = 1.00 // ab diesem Fortschritt gilt "fertig" +const HOLD_MS = 1200 +const COMPLETE_THRESHOLD = 1.0 export default function MapVetoPanel({ match }: Props) { const { data: session } = useSession() @@ -41,32 +43,29 @@ export default function MapVetoPanel({ match }: Props) { const isOpenFromMatch = nowTs >= opensAtTs // --- Rollen --- - const me = session?.user - 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 me = session?.user + const isAdmin = !!me?.isAdmin const isLeaderA = !!me?.steamId && match.teamA?.leader?.steamId === me.steamId const isLeaderB = !!me?.steamId && match.teamB?.leader?.steamId === me.steamId - console.log("me.steamId: ", me?.steamId); - console.log("match.teamA?.leader?.steamId: ", match.teamA?.leader?.steamId); - console.log("match.teamB?.leader?.steamId: ", match.teamB?.leader?.steamId); - - const canActForTeamId = useCallback((teamId?: string | null) => { - if (!teamId) return false - if (isAdmin) return true - return (teamId === match.teamA?.id && isLeaderA) || - (teamId === match.teamB?.id && isLeaderB) - }, [isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id]) + const canActForTeamId = useCallback( + (teamId?: string | null) => { + if (!teamId) return false + if (isAdmin) return true + return ( + (teamId === match.teamA?.id && isLeaderA) || + (teamId === match.teamB?.id && isLeaderB) + ) + }, + [isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id], + ) // --- Laden / Reload --- const load = useCallback(async () => { setIsLoading(true) setError(null) 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) { const j = await r.json().catch(() => ({})) throw new Error(j?.message || 'Laden fehlgeschlagen') @@ -84,7 +83,9 @@ export default function MapVetoPanel({ match }: Props) { } }, [match.id]) - useEffect(() => { load() }, [load]) + useEffect(() => { + load() + }, [load]) // --- SSE: live nachladen --- useEffect(() => { @@ -96,33 +97,36 @@ export default function MapVetoPanel({ match }: Props) { }, [lastEvent, match.id, load]) // --- Abgeleitet --- - const opensAt = useMemo(() => state?.opensAt ? new Date(state.opensAt).getTime() : null, [state?.opensAt]) - const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch + const opensAt = useMemo( + () => (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 currentStep = state?.steps?.[state?.currentIndex ?? 0] const isMyTurn = Boolean( - isOpen && !state?.locked && currentStep?.teamId && canActForTeamId(currentStep.teamId) + isOpen && !state?.locked && currentStep?.teamId && canActForTeamId(currentStep.teamId), ) const mapPool = state?.mapPool ?? [] // Map -> (action, teamId) wenn bereits entschieden const decisionByMap = useMemo(() => { - const map = new Map() - for (const s of (state?.steps ?? [])) { + const map = new Map() + for (const s of state?.steps ?? []) { if (s.map) map.set(s.map, { action: s.action as any, teamId: s.teamId ?? null }) } return map }, [state?.steps]) - const fmt = (k: string) => mapNameMap[k]?.name ?? k + const fmt = (k: string) => MAP_OPTIONS.find((m) => m.key === k)?.label ?? k // --- Aktionen --- const handlePickOrBan = async (map: string) => { if (!isMyTurn || !currentStep) return try { - const r = await fetch(`/api/matches/${match.id}/map-vote`, { + const r = await fetch(`/api/matches/${match.id}/mapvote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ map }), @@ -132,7 +136,19 @@ export default function MapVetoPanel({ match }: Props) { alert(j.message ?? 'Aktion fehlgeschlagen') 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 { alert('Netzwerkfehler') } @@ -142,7 +158,7 @@ export default function MapVetoPanel({ match }: Props) { const rafRef = useRef(null) const holdStartRef = useRef(null) const holdMapRef = useRef(null) - const submittedRef = useRef(false) // gegen Doppel-Submit + const submittedRef = useRef(false) const [progressByMap, setProgressByMap] = useState>({}) const resetHold = useCallback(() => { @@ -153,50 +169,73 @@ export default function MapVetoPanel({ match }: Props) { submittedRef.current = false }, []) - const finishAndSubmit = useCallback((map: string) => { - if (submittedRef.current) return - submittedRef.current = true - setTimeout(() => handlePickOrBan(map), 10) - }, [handlePickOrBan]) + const finishAndSubmit = useCallback( + (map: string) => { + if (submittedRef.current) return + submittedRef.current = true + setTimeout(() => handlePickOrBan(map), 10) + }, + [handlePickOrBan], + ) - const stepHold = useCallback((ts: number) => { - if (!holdStartRef.current || !holdMapRef.current) return - const elapsed = ts - holdStartRef.current - const p = Math.min(1, elapsed / HOLD_MS) - const map = holdMapRef.current + const stepHold = useCallback( + (ts: number) => { + if (!holdStartRef.current || !holdMapRef.current) return + const elapsed = ts - holdStartRef.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) { - const doneMap = map + if (p >= COMPLETE_THRESHOLD) { + const doneMap = map + resetHold() + finishAndSubmit(doneMap) + return + } + rafRef.current = requestAnimationFrame(stepHold) + }, + [resetHold, finishAndSubmit], + ) + + const onHoldStart = useCallback( + (map: string, allowed: boolean) => { + if (!allowed) return resetHold() - finishAndSubmit(doneMap) - return - } - rafRef.current = requestAnimationFrame(stepHold) - }, [resetHold, finishAndSubmit]) + holdMapRef.current = map + holdStartRef.current = performance.now() + setProgressByMap((prev) => ({ ...prev, [map]: 0 })) + rafRef.current = requestAnimationFrame(stepHold) + }, + [stepHold, resetHold], + ) - const onHoldStart = useCallback((map: string, allowed: boolean) => { - if (!allowed) return - resetHold() - holdMapRef.current = map - holdStartRef.current = performance.now() - setProgressByMap(prev => ({ ...prev, [map]: 0 })) - rafRef.current = requestAnimationFrame(stepHold) - }, [stepHold, resetHold]) + const cancelOrSubmitIfComplete = useCallback( + (map: string) => { + const p = progressByMap[map] ?? 0 + if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) { + resetHold() + finishAndSubmit(map) + return + } + if (holdMapRef.current === map) { + resetHold() + setProgressByMap((prev) => ({ ...prev, [map]: 0 })) + } + }, + [progressByMap, resetHold, finishAndSubmit], + ) - const cancelOrSubmitIfComplete = useCallback((map: string) => { - const p = progressByMap[map] ?? 0 - if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) { - resetHold() - finishAndSubmit(map) - return + const deciderChooserTeamId = useMemo(() => { + const steps = state?.steps ?? [] + const decIdx = steps.findIndex(s => s.action === 'decider') + if (decIdx < 0) return null + for (let i = decIdx - 1; i >= 0; i--) { + const s = steps[i] + if (s.action === 'ban' && s.teamId) return s.teamId } - if (holdMapRef.current === map) { - resetHold() - setProgressByMap(prev => ({ ...prev, [map]: 0 })) - } - }, [progressByMap, resetHold, finishAndSubmit]) + return null + }, [state?.steps]) // Touch-UnterstΓΌtzung const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => { @@ -208,206 +247,365 @@ export default function MapVetoPanel({ match }: Props) { cancelOrSubmitIfComplete(map) } - if (isLoading && !state) return
Lade Map-Voting…
- if (error && !state) return
{error}
- - const playersA = match.teamA.players as unknown as MatchPlayer[] - const playersB = match.teamB.players as unknown as MatchPlayer[] + // --- Spielerlisten ableiten (Hooks bleiben IMMER aktiv) --- + const playersA = useMemo(() => { + // 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 + + // 1) Klassischer Weg: match.players via Roster (teamAUsers) filtern + 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(() => { + 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 (
- {/* Header */} -
-

Map-Vote

-
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
-
- - {/* Countdown / Status */} - {!isOpen && ( -
- - Γ–ffnet in {formatCountdown(msToOpen)} - -
- )} - - {/* Hauptbereich */} - {state && ( -
- {/* Links – Team A */} - - - {/* Mitte – Maps untereinander (kompakt + Hold-to-confirm) */} -
-
    - {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 ( -
  • -
+ ) : showError ? ( +
{error}
+ ) : ( + <> + {/* Header */} +
+

Map-Vote

+
+
+ Modus: BO{match.bestOf ?? state?.bestOf ?? 3} +
+ {isAdmin && ( + - - ) - })} - - - {/* Footer-Status */} -
- {state.locked ? ( - - Veto abgeschlossen - - ) : isOpen ? ( - isMyTurn ? ( - - Halte gedrΓΌckt, um zu bestΓ€tigen - - ) : ( - - Wartet auf  - {currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name} -  (Leader/Admin) - - ) - ) : null} - - {error && ( - - {error} - + // SSE feuert ohnehin; zusΓ€tzlich lokal nachladen: + await load() + } catch { + alert('Netzwerkfehler beim Reset') + } + }} + /> )}
- +
- {/* Rechts – Team B */} - -
+ {/* Countdown / Status */} + {!isOpen && ( +
+ + Γ–ffnet in {formatCountdown(msToOpen)} + +
+ )} + + {/* Countdown / Status ganz oben und grâßer */} +
+ {state?.locked ? ( + + βœ… Veto abgeschlossen + + ) : isOpen ? ( + isMyTurn ? ( + + βœ‹ Halte gedrΓΌckt, um zu bestΓ€tigen + + ) : ( + + ⏳ Wartet auf  + {currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name} +  (Leader/Admin) + + ) + ) : ( + + Γ–ffnet in {formatCountdown(msToOpen)} + + )} + + {error && ( + + {error} + + )} +
+ + {/* Hauptbereich */} + {state && ( +
+ {/* Links – Team A */} + + + {/* Mitte – Maps (Hold-to-confirm) */} +
+
    + {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 ( +
  • + {/* linker Slot */} + {pickedByA ? ( + {match.teamA?.name + ) : ( +
    + )} + + {/* Button */} + + + {/* rechter Slot */} + {pickedByB ? ( + {match.teamB?.name + ) : ( +
    + )} +
  • + ) + })} +
+
+ + {/* Rechts – Team B */} + +
+ )} + )}
) @@ -419,6 +617,6 @@ function formatCountdown(ms: number) { const h = Math.floor(totalSec / 3600) const m = Math.floor((totalSec % 3600) / 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)}` } diff --git a/src/app/components/MatchDetails.tsx b/src/app/components/MatchDetails.tsx index cd3fbe5..8bf37a8 100644 --- a/src/app/components/MatchDetails.tsx +++ b/src/app/components/MatchDetails.tsx @@ -1,36 +1,42 @@ /* ──────────────────────────────────────────────────────────────── /app/components/MatchDetails.tsx - Zeigt pro Team einen eigenen β€žSpieler bearbeitenβ€œ-Button und ΓΆffnet - das Modal nur fΓΌr das angeklickte Team. + - Zeigt pro Team einen eigenen β€žSpieler bearbeitenβ€œ-Button + - Γ–ffnet das Modal nur fΓΌr das angeklickte Team + - Reagiert auf SSE-Events (match-lineup-updated / matches-updated) ─────────────────────────────────────────────────────────────────*/ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' -import { format } from 'date-fns' -import { de } from 'date-fns/locale' +import { format } from 'date-fns' +import { de } from 'date-fns/locale' -import Table from './Table' -import PremierRankBadge from './PremierRankBadge' -import CompRankBadge from './CompRankBadge' +import Table from './Table' +import PremierRankBadge from './PremierRankBadge' +import CompRankBadge from './CompRankBadge' +import EditMatchMetaModal from './EditMatchMetaModal' import EditMatchPlayersModal from './EditMatchPlayersModal' -import type { EditSide } from './EditMatchPlayersModal' // 'A' | 'B' +import type { EditSide } from './EditMatchPlayersModal' import type { Match, MatchPlayer } from '../types/match' import Button from './Button' -import { mapNameMap } from '../lib/mapNameMap' +import { MAP_OPTIONS } from '../lib/mapOptions' import MapVetoBanner from './MapVetoBanner' -import MapVetoPanel from './MapVetoPanel' 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[] } /* ─────────────────── Hilfsfunktionen ────────────────────────── */ const kdr = (k?: number, 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) => @@ -38,49 +44,84 @@ const adr = (dmg?: number, rounds?: number) => ? (dmg / rounds).toFixed(1) : '-' +const normalizeMapKey = (raw?: string) => + (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '') + /* ─────────────────── Komponente ─────────────────────────────── */ -export function MatchDetails ({ match }: { match: Match }) { +export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) { const { data: session } = useSession() - const { lastEvent } = useSSEStore() - const router = useRouter() + const { lastEvent } = useSSEStore() + const router = useRouter() const isAdmin = !!session?.user?.isAdmin + const [now, setNow] = useState(initialNow) + const [editMetaOpen, setEditMetaOpen] = useState(false) /* ─── Rollen & Rechte ─────────────────────────────────────── */ - const me = session?.user - const userId = me?.steamId - const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId - const isLeaderB = !!userId && userId === match.teamB?.leader?.steamId - const canEditA = isAdmin || isLeaderA - const canEditB = isAdmin || isLeaderB - - const isMapVetoOpen = !!match.mapVeto?.isOpen + const me = session?.user + const userId = me?.steamId + const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId + const isLeaderB = !!userId && userId === match.teamB?.leader?.steamId + const canEditA = isAdmin || isLeaderA + const canEditB = isAdmin || isLeaderB const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? [] const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? [] /* ─── Map ─────────────────────────────────────────────────── */ - const normalizeMapKey = (raw?: string) => - (raw ?? '') - .toLowerCase() - .replace(/\.bsp$/,'') - .replace(/^.*\//,'') - - const mapKey = normalizeMapKey(match.map) - const mapLabel = mapNameMap[mapKey]?.name ?? (match.map ?? 'Unbekannte Map') + const mapKey = normalizeMapKey(match.map) + const mapLabel = + MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ?? + MAP_OPTIONS.find(opt => opt.key === 'lobby_mapveto')?.label ?? + 'Unbekannte Map' /* ─── Match-Zeitpunkt ─────────────────────────────────────── */ - const dateString = match.matchDate ?? match.demoDate - const isFutureMatch = !!dateString && new Date(dateString).getTime() > Date.now() + const dateString = match.matchDate ?? match.demoDate + const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt' /* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */ const [editSide, setEditSide] = useState(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) ───────────────────────── */ const ColGroup = () => ( - - {Array.from({ length: 12 }).map((_, i) => ( + + {Array.from({ length: 13 }).map((_, i) => ( ))} @@ -96,82 +137,85 @@ export function MatchDetails ({ match }: { match: Match }) { alert(j.message ?? 'LΓΆschen fehlgeschlagen') return } - // ZurΓΌck zur Matchliste - router.push('/schedule') // ggf. an deinen Pfad anpassen + router.push('/schedule') } catch (e) { console.error('[MatchDetails] delete failed', e) alert('LΓΆschen fehlgeschlagen.') } } - /* ─── Spieler-Tabelle ─────────────────────────────────────── */ + /* ─── Spieler-Tabelle (pure; keine Hooks hier drin!) ──────── */ const renderTable = (players: MatchPlayer[]) => { const sorted = [...players].sort( (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 ( - {['Spieler','Rank','Aim','K','A','D','1K','2K','3K','4K','5K', - 'K/D','ADR','HS%','Damage'].map(h => ( - {h} + {[ + 'Spieler', + 'Rank', + 'Aim', + 'K', + 'A', + 'D', + '1K', + '2K', + '3K', + '4K', + '5K', + 'K/D', + 'ADR', + 'HS%', + 'Damage', + ].map((h) => ( + + {h} + ))} - {sorted.map(p => ( - - router.push(`/profile/${p.user.steamId}`)} > + {sorted.map((p) => ( + + router.push(`/profile/${p.user.steamId}`)} + > {p.user.name} -
- {p.user.name ?? 'Unbekannt'} -
+
{p.user.name ?? 'Unbekannt'}
- {match.matchType === 'premier' - ? - : } - {match.matchType === 'premier' && - typeof p.stats?.rankChange === 'number' && ( - 0 ? 'text-green-500' - : p.stats.rankChange < 0 ? 'text-red-500' : ''}`}> - {p.stats.rankChange > 0 ? '+' : ''} - {p.stats.rankChange} - - )} + {match.matchType === 'premier' ? ( + + ) : ( + + )} + {match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && ( + 0 + ? 'text-green-500' + : p.stats.rankChange < 0 + ? 'text-red-500' + : '' + }`} + > + {p.stats.rankChange > 0 ? '+' : ''} + {p.stats.rankChange} + + )}
@@ -180,14 +224,14 @@ export function MatchDetails ({ match }: { match: Match }) { ? `${Number(p.stats?.aim).toFixed(0)} %` : '-'}
- {p.stats?.kills ?? '-'} - {p.stats?.assists ?? '-'} - {p.stats?.deaths ?? '-'} - {p.stats?.oneK ?? '-'} - {p.stats?.twoK ?? '-'} - {p.stats?.threeK ?? '-'} - {p.stats?.fourK ?? '-'} - {p.stats?.fiveK ?? '-'} + {p.stats?.kills ?? '-'} + {p.stats?.assists ?? '-'} + {p.stats?.deaths ?? '-'} + {p.stats?.oneK ?? '-'} + {p.stats?.twoK ?? '-'} + {p.stats?.threeK ?? '-'} + {p.stats?.fourK ?? '-'} + {p.stats?.fiveK ?? '-'} {kdr(p.stats?.kills, p.stats?.deaths)} {adr(p.stats?.totalDamage, match.roundCount)} {((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}% @@ -199,58 +243,56 @@ export function MatchDetails ({ match }: { match: Match }) { ) } - /* ─── Ausgabe-Datum ───────────────────────────────────────── */ - const readableDate = dateString - ? format(new Date(dateString), 'PPpp', { locale: de }) - : 'Unbekannt' - /* ─── Render ─────────────────────────────────────────────── */ return (

Match auf {mapLabel} ({match.matchType})

- + {isAdmin && ( - +
+ + +
)}

Datum: {readableDate}

- Teams:{' '} - {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'} + Teams: {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
Score: {match.scoreA ?? 0}:{match.scoreB ?? 0}
- + {/* ───────── Team-BlΓΆcke ───────── */}
{/* Team A */}
-

- {match.teamA?.name ?? 'Team A'} -

+

{match.teamA?.name ?? 'Team A'}

- {canEditA && isFutureMatch && ( - + {showEditA && ( + + + Du kannst die Aufstellung noch bis {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten. + + + )}
@@ -261,18 +303,38 @@ export function MatchDetails ({ match }: { match: Match }) {

+ {match.teamB?.logo && ( + + + + )} {match.teamB?.name ?? 'Team B'}

- {canEditB && isFutureMatch && ( - + {showEditB && ( + + + Du kannst die Aufstellung noch bis {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten. + + + )}
@@ -289,9 +351,26 @@ export function MatchDetails ({ match }: { match: Match }) { teamA={match.teamA} teamB={match.teamB} side={editSide} - initialA={teamAPlayers.map(mp => mp.user.steamId)} - initialB={teamBPlayers.map(mp => mp.user.steamId)} - onSaved={() => window.location.reload()} + initialA={teamAPlayers.map((mp) => mp.user.steamId)} + initialB={teamBPlayers.map((mp) => mp.user.steamId)} + onSaved={() => router.refresh()} // sanfter als window.location.reload() + /> + )} + + {editMetaOpen && ( + 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() }} /> )}
diff --git a/src/app/components/Select.tsx b/src/app/components/Select.tsx index fef4d20..9475f80 100644 --- a/src/app/components/Select.tsx +++ b/src/app/components/Select.tsx @@ -1,10 +1,8 @@ -import { useState, useRef, useEffect } from "react"; - -type Option = { - value: string; - label: string; -}; +// Select.tsx +import { useState, useRef, useEffect, useMemo } from "react"; +import { createPortal } from "react-dom"; +type Option = { value: string; label: string }; type SelectProps = { options: Option[]; placeholder?: string; @@ -14,65 +12,89 @@ type SelectProps = { 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 ref = useRef(null); 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(null); const buttonRef = useRef(null); + const menuRef = useRef(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(() => { - if (open && dropDirection === "auto" && buttonRef.current) { - requestAnimationFrame(() => { - const rect = buttonRef.current!.getBoundingClientRect(); - const dropdownHeight = 200; - const spaceBelow = window.innerHeight - rect.bottom; - const spaceAbove = rect.top; - - if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) { - setDirection("up"); - } else { - setDirection("down"); - } - }); - } - }, [open, dropDirection]); + if (!open) return; + computePosition(); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (ref.current && !ref.current.contains(event.target as Node)) { - setOpen(false); - } + const onScroll = () => computePosition(); + const onResize = () => computePosition(); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", onResize); + + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; + document.addEventListener("keydown", onKey); + + return () => { + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", onResize); + document.removeEventListener("keydown", onKey); }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, dropDirection]); + + // Click-outside: ignoriert Klicks im Portal-MenΓΌ + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + const t = event.target as Node; + const clickedInsideRoot = !!rootRef.current?.contains(t); + const clickedInsideMenu = !!menuRef.current?.contains(t); + if (!clickedInsideRoot && !clickedInsideMenu) setOpen(false); + }; + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); }, []); - const selectedOption = options.find(o => o.value === value); - - return ( -
- - {open && ( + const Menu = open + ? createPortal(
    - {options.map((option) => ( + {options.map(option => (
  • { @@ -86,8 +108,27 @@ export default function Select({ options, placeholder = "Select option...", valu {option.label}
  • ))} -
- )} + , + document.body + ) + : null; + + return ( +
+ + {Menu}
); -} \ No newline at end of file +} diff --git a/src/app/components/profile/[steamId]/matches/UserMatchesList.tsx b/src/app/components/profile/[steamId]/matches/UserMatchesList.tsx index 138a665..3eea1e1 100644 --- a/src/app/components/profile/[steamId]/matches/UserMatchesList.tsx +++ b/src/app/components/profile/[steamId]/matches/UserMatchesList.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation' import Table from '../../../Table' import PremierRankBadge from '../../../PremierRankBadge' import CompRankBadge from '../../../CompRankBadge' -import { mapNameMap } from '@/app/lib/mapNameMap' +import { MAP_OPTIONS } from '@/app/lib/mapOptions' import LoadingSpinner from '@/app/components/LoadingSpinner' interface Match { @@ -198,7 +198,9 @@ export default function UserMatchesList({ steamId }: { steamId: string }) { {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 ownCTSide = m.team !== 'T' @@ -221,11 +223,11 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
{mapInfo.name} - {mapInfo.name} + {mapInfo?.label}
diff --git a/src/app/lib/mapNameMap.ts b/src/app/lib/mapNameMap.ts deleted file mode 100644 index 6f9722d..0000000 --- a/src/app/lib/mapNameMap.ts +++ /dev/null @@ -1,24 +0,0 @@ -// src/lib/mapNameMap.ts - -export const mapNameMap: Record = { - 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' }, -}; diff --git a/src/app/lib/mapOptions.ts b/src/app/lib/mapOptions.ts new file mode 100644 index 0000000..67e20f4 --- /dev/null +++ b/src/app/lib/mapOptions.ts @@ -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' }, +] diff --git a/src/app/lib/sseEvents.ts b/src/app/lib/sseEvents.ts index 0a33f9f..4bb8db7 100644 --- a/src/app/lib/sseEvents.ts +++ b/src/app/lib/sseEvents.ts @@ -1,5 +1,4 @@ // sseEvents.ts - export const SSE_EVENT_TYPES = [ // Kanonisch 'team-updated', @@ -24,6 +23,10 @@ export const SSE_EVENT_TYPES = [ 'match-created', 'matches-updated', 'match-deleted', + 'match-updated', + + // βž• neu: gezieltes Event, wenn sich die Aufstellung Γ€ndert + 'match-lineup-updated', ] as const; export type SSEEventType = typeof SSE_EVENT_TYPES[number]; @@ -57,6 +60,15 @@ export const SELF_EVENTS: ReadonlySet = new Set([ 'team-leader-self', ]); +// βž• neu: Match-bezogene Events als Gruppe +export const MATCH_EVENTS: ReadonlySet = new Set([ + 'match-created', + 'matches-updated', + 'match-deleted', + 'match-lineup-updated', + 'match-updated', +]); + // Event-Typen, die das NotificationCenter betreffen export const NOTIFICATION_EVENTS: ReadonlySet = new Set([ 'notification', diff --git a/src/app/match-details/[matchId]/MatchContext.tsx b/src/app/match-details/[matchId]/MatchContext.tsx new file mode 100644 index 0000000..34aa2cf --- /dev/null +++ b/src/app/match-details/[matchId]/MatchContext.tsx @@ -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(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 {children} +} diff --git a/src/app/match-details/[matchId]/MatchDetailsClient.tsx b/src/app/match-details/[matchId]/MatchDetailsClient.tsx new file mode 100644 index 0000000..2f97657 --- /dev/null +++ b/src/app/match-details/[matchId]/MatchDetailsClient.tsx @@ -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 +} diff --git a/src/app/match-details/[matchId]/layout.tsx b/src/app/match-details/[matchId]/layout.tsx new file mode 100644 index 0000000..76b5075 --- /dev/null +++ b/src/app/match-details/[matchId]/layout.tsx @@ -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 { + 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 {children} +} diff --git a/src/app/match-details/[matchId]/map-vote/page.tsx b/src/app/match-details/[matchId]/map-vote/page.tsx deleted file mode 100644 index 84b7892..0000000 --- a/src/app/match-details/[matchId]/map-vote/page.tsx +++ /dev/null @@ -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 ( - - - - ) -} diff --git a/src/app/match-details/[matchId]/page.tsx b/src/app/match-details/[matchId]/page.tsx index ddf49b5..7409d1c 100644 --- a/src/app/match-details/[matchId]/page.tsx +++ b/src/app/match-details/[matchId]/page.tsx @@ -1,28 +1,6 @@ -// /app/match-details/[matchId]/page.tsx -import Card from '@/app/components/Card' -import { MatchDetails } from '@/app/components/MatchDetails' -import type { Match } from '@/app/types/match' +// app/match-details/[matchId]/page.tsx +import MatchDetailsClient from './MatchDetailsClient' -interface PageProps { - params: { - 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
Fehler beim Laden des Matches
- } - - const match: Match = await res.json() - - return ( - - - - ) +export default function MatchDetailsPage() { + return } diff --git a/src/app/match-details/[matchId]/vote/VoteClient.tsx b/src/app/match-details/[matchId]/vote/VoteClient.tsx new file mode 100644 index 0000000..111525d --- /dev/null +++ b/src/app/match-details/[matchId]/vote/VoteClient.tsx @@ -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 +} diff --git a/src/app/match-details/[matchId]/vote/page.tsx b/src/app/match-details/[matchId]/vote/page.tsx new file mode 100644 index 0000000..de7ffb7 --- /dev/null +++ b/src/app/match-details/[matchId]/vote/page.tsx @@ -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 ( + + + + ) +} diff --git a/src/jobs/processAllUsersCron.ts b/src/jobs/processAllUsersCron.ts index 8bcf25b..9887efa 100644 --- a/src/jobs/processAllUsersCron.ts +++ b/src/jobs/processAllUsersCron.ts @@ -10,9 +10,50 @@ import { updatePremierRanksForUser } from './updatePremierRanks'; import fs from 'fs'; import path from 'path'; - 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___.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() { log('πŸš€ CS2-CronJob Runner gestartet!'); const job = cron.schedule('* * * * * *', async () => { @@ -36,7 +77,7 @@ async function runMatchCheck() { for (const user of users) { const decryptedAuthCode = decrypt(user.authCode!); - const allNewMatches = []; + const allNewMatches: { id: string }[] = []; let latestKnownCode = user.lastKnownShareCode!; let nextShareCode = await getNextShareCodeFromAPI(user.steamId, decryptedAuthCode, latestKnownCode); @@ -92,7 +133,7 @@ async function runMatchCheck() { }); if (existingMatch) { - // log(`[${user.steamId}] β†ͺ️ Match ${matchInfo.matchId} existiert bereits – ΓΌbersprungen`); + // Match ist bereits in der DB – ΓΌberspringen await prisma.user.update({ where: { steamId: user.steamId }, data: { lastKnownShareCode: nextShareCode }, @@ -120,12 +161,18 @@ async function runMatchCheck() { const shareCode = encodeMatch(matchInfo); - const expectedFilename = `${matchInfo.matchId}.dem`; - const expectedFilePath = path.join(process.cwd(), 'demos', expectedFilename); + // ⬇️ NEU: im demos-Ordner (YYYY-MM-DD Unterordner) nach existierender Demo suchen + const demosRoot = path.join(process.cwd(), 'demos'); + const existingDemoPath = findExistingDemoByMatchId(demosRoot, matchInfo.matchId.toString()); + + if (existingDemoPath) { + 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. - if (fs.existsSync(expectedFilePath)) { - log(`[${user.steamId}] πŸ“ Match ${matchInfo.matchId} wurde bereits als Datei gespeichert – ΓΌbersprungen`); - await prisma.user.update({ where: { steamId: user.steamId }, data: { lastKnownShareCode: nextShareCode }, @@ -136,6 +183,7 @@ async function runMatchCheck() { continue; } + // kein File vorhanden -> Downloader/Parser anstoßen const result = await runDownloaderForUser({ ...user, lastKnownShareCode: shareCode, diff --git a/src/jobs/runDownloaderForUser.ts b/src/jobs/runDownloaderForUser.ts index 830b687..296e361 100644 --- a/src/jobs/runDownloaderForUser.ts +++ b/src/jobs/runDownloaderForUser.ts @@ -1,146 +1,71 @@ -import fs from 'fs/promises' -import path from 'path' -import type { Match, User } from '@/generated/prisma' -import { parseAndStoreDemo } from './parseAndStoreDemo' -import { log } from '../../scripts/cs2-cron-runner.js' -import { prisma } from '../app/lib/prisma.js' - -type DownloadResponse = { - success: boolean - path?: string - matchId?: string - error?: string -} - -const isWinAbs = (p: string) => /^[a-zA-Z]:\\/.test(p) -const isUnixAbs = (p: string) => p.startsWith('/') - -/** Extrahiert matchId aus einem Dateinamen als Fallback (falls der Downloader sie nicht mitliefert). */ -const extractMatchIdFromName = (name: string): string | null => { - // Beispiele: match730_de_inferno_3762944197338858088_competitive.dem - const m = name.match(/match\d+_[^_]+_(\d+)_/) - return m?.[1] ?? null -} +import fs from 'fs/promises'; +import path from 'path'; +import { Match, User } from '@/generated/prisma'; +import { parseAndStoreDemo } from './parseAndStoreDemo'; +import { log } from '../../scripts/cs2-cron-runner.js'; +import { prisma } from '../app/lib/prisma.js'; export async function runDownloaderForUser(user: User): Promise<{ - newMatches: Match[] - latestShareCode: string | null + newMatches: Match[]; + latestShareCode: string | null; }> { if (!user.authCode || !user.lastKnownShareCode) { - throw new Error(`User ${user.steamId}: authCode oder ShareCode fehlt`) + throw new Error(`User ${user.steamId}: authCode oder ShareCode fehlt`); } - const steamId = user.steamId - const shareCode = user.lastKnownShareCode + const steamId = user.steamId; + const shareCode = user.lastKnownShareCode; + + log(`[${user.steamId}] πŸ“₯ Lade Demo herunter...`); - log(`[${steamId}] πŸ“₯ Lade Demo herunter...`) + // 🎯 Nur HTTP-Modus + const res = await fetch('http://localhost:4000/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ steamId, shareCode }), + }); - // ───────────────────────── HTTP-Aufruf an Downloader ───────────────────────── - let data: DownloadResponse - try { - const res = await fetch('http://localhost:4000/download', { - method : 'POST', - headers: { 'Content-Type': 'application/json' }, - body : JSON.stringify({ steamId, shareCode }), - }) + const data = await res.json(); - if (!res.ok) { - const text = await res.text().catch(() => '') - log(`[${steamId}] ❌ Downloader HTTP ${res.status}: ${text || res.statusText}`, 'error') - return { newMatches: [], latestShareCode: shareCode } - } - - data = (await res.json()) as DownloadResponse - } catch (err: any) { - log(`[${steamId}] ❌ Downloader-Netzwerkfehler: ${err?.message ?? String(err)}`, 'error') - return { newMatches: [], latestShareCode: shareCode } + if (!data.success) { + log(`[${steamId}] ❌ Downloader-Fehler: ${data.error}`, 'error'); } - if (!data?.success) { - log(`[${steamId}] ❌ Downloader-Fehler: ${data?.error ?? 'unbekannt'}`, 'error') - return { newMatches: [], latestShareCode: shareCode } - } - - let demoPath: string | undefined = data.path - const matchIdFromResp: string | undefined = data.matchId ?? undefined + const demoPath = data.path; if (!demoPath) { - log(`[${steamId}] ⚠️ Kein Demo-Pfad erhalten – Match wird ΓΌbersprungen`, 'warn') - return { newMatches: [], latestShareCode: shareCode } + log(`[${steamId}] ⚠️ Kein Demo-Pfad erhalten – Match wird ΓΌbersprungen`, 'warn'); + return { newMatches: [], latestShareCode: shareCode }; } - // ───────────────────────── Pfad plattformneutral absolut machen ───────────── - let absolutePath = (isWinAbs(demoPath) || isUnixAbs(demoPath)) - ? demoPath - : path.resolve(process.cwd(), demoPath) // falls relativ geliefert + const filename = path.basename(demoPath); + const matchId = filename.replace(/\.dem$/, ''); - // ───────────────────────── Existenz prΓΌfen; ggf. Fallback mit matchId ─────── - try { - await fs.access(absolutePath) - } catch { - // Datei fehlt – evtl. anderer Mapname im Dateinamen. Versuche, anhand matchId zu finden. - const dir = path.dirname(absolutePath) - const justName = demoPath.split(/[/\\]/).pop() ?? '' - const fallbackMatchId = matchIdFromResp ?? extractMatchIdFromName(justName) ?? '' + const existing = await prisma.match.findUnique({ + where: { id: matchId }, + }); - try { - const entries = await fs.readdir(dir) - const hit = entries.find(n => - n.endsWith('.dem') && - (fallbackMatchId ? n.includes(`_${fallbackMatchId}_`) : false), - ) - - if (hit) { - absolutePath = path.join(dir, hit) - log(`[${steamId}] πŸ”Ž Pfad korrigiert: ${absolutePath}`, 'info') - } else { - log(`[${steamId}] ⚠️ Datei nicht gefunden: ${absolutePath}`, 'warn') - return { newMatches: [], latestShareCode: shareCode } - } - } catch (e) { - log(`[${steamId}] ⚠️ Verzeichnis nicht lesbar: ${dir}`, 'warn') - return { newMatches: [], latestShareCode: shareCode } - } + if (existing) { + log(`[${steamId}] πŸ” Match ${matchId} wurde bereits analysiert – ΓΌbersprungen`, 'info'); + return { newMatches: [], latestShareCode: shareCode }; } - // ───────────────────────── matchId bestimmen (DB-Duplikat-Check) ──────────── - const matchId = - matchIdFromResp ?? - extractMatchIdFromName(demoPath.split(/[/\\]/).pop() ?? '') ?? - '' + log(`[${steamId}] πŸ“‚ Analysiere: ${filename}`); - if (!matchId) { - log(`[${steamId}] ⚠️ Konnte matchId nicht ermitteln – ΓΌbersprungen`, 'warn') - return { newMatches: [], latestShareCode: shareCode } - } + const absolutePath = path.resolve(__dirname, '../../../cs2-demo-downloader', demoPath); + const match = await parseAndStoreDemo(absolutePath, steamId, shareCode); - const existsInDb = await prisma.match.findUnique({ where: { id: matchId } }) - if (existsInDb) { - log(`[${steamId}] πŸ” Match ${matchId} wurde bereits analysiert – ΓΌbersprungen`, 'info') - return { newMatches: [], latestShareCode: shareCode } - } + const newMatches: Match[] = []; - const filename = absolutePath.split(/[/\\]/).pop() ?? 'demo.dem' - log(`[${steamId}] πŸ“‚ Analysiere: ${filename}`) - - // ───────────────────────── Parser starten ─────────────────────────────────── - let match: Match | null = null - try { - match = await parseAndStoreDemo(absolutePath, steamId, shareCode) - } catch (err: any) { - log(`[${steamId}] ❌ Analysefehler: ${err?.message ?? String(err)}`, 'error') - } - - const newMatches: Match[] = [] if (match) { - newMatches.push(match) - log(`[${steamId}] βœ… Match gespeichert: ${match.id}`) + newMatches.push(match); + log(`[${steamId}] βœ… Match gespeichert: ${match.id}`); } else { - log(`[${steamId}] ⚠️ Match bereits vorhanden oder Analyse fehlgeschlagen`, 'warn') + log(`[${steamId}] ⚠️ Match bereits vorhanden oder Analyse fehlgeschlagen`, 'warn'); } return { newMatches, latestShareCode: shareCode, - } + }; }