updated
This commit is contained in:
parent
61c75b1c8c
commit
6caf57d282
@ -1,230 +0,0 @@
|
||||
// /app/api/matches/[id]/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/app/lib/auth'
|
||||
|
||||
export async function GET(_: Request, context: { params: { id: string } }) {
|
||||
const { id } = context.params
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
players: {
|
||||
include: {
|
||||
user: true,
|
||||
stats: true,
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
teamAUsers: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
teamBUsers: {
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!match) {
|
||||
return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const teamAIds = new Set(match.teamAUsers.map(u => u.steamId));
|
||||
const teamBIds = new Set(match.teamBUsers.map(u => u.steamId));
|
||||
|
||||
const playersA = match.players
|
||||
.filter(p => teamAIds.has(p.steamId))
|
||||
.map(p => ({
|
||||
user: p.user,
|
||||
stats: p.stats,
|
||||
team: p.team?.name ?? 'Team A',
|
||||
}));
|
||||
|
||||
const playersB = match.players
|
||||
.filter(p => teamBIds.has(p.steamId))
|
||||
.map(p => ({
|
||||
user: p.user,
|
||||
stats: p.stats,
|
||||
team: p.team?.name ?? 'Team B',
|
||||
}));
|
||||
|
||||
const teamA = {
|
||||
name: match.teamAUsers[0]?.team?.name ?? 'Team A',
|
||||
logo: null,
|
||||
score: match.scoreA,
|
||||
players: playersA,
|
||||
};
|
||||
|
||||
const teamB = {
|
||||
name: match.teamBUsers[0]?.team?.name ?? 'Team B',
|
||||
logo: null,
|
||||
score: match.scoreB,
|
||||
players: playersB,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
id: match.id,
|
||||
title: match.title,
|
||||
description: match.description,
|
||||
demoDate: match.demoDate,
|
||||
matchType: match.matchType,
|
||||
roundCount: match.roundCount,
|
||||
map: match.map,
|
||||
teamA,
|
||||
teamB,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`GET /matches/${id} failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, context: { params: { id: string } }) {
|
||||
const { id } = context.params
|
||||
const session = await getServerSession(authOptions(req))
|
||||
const userId = session?.user?.steamId
|
||||
const isAdmin = session?.user?.isAdmin
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { title, description, matchDate, players } = body
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { steamId: userId },
|
||||
include: { ledTeam: true },
|
||||
});
|
||||
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const isTeamLeaderA = match.teamAId && user?.ledTeam?.id === match.teamAId;
|
||||
const isTeamLeaderB = match.teamBId && user?.ledTeam?.id === match.teamBId;
|
||||
|
||||
if (!isAdmin && !isTeamLeaderA && !isTeamLeaderB) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// 🛡️ Validierung: Nur eigene Spieler
|
||||
if (!isAdmin) {
|
||||
const ownTeamId = isTeamLeaderA ? match.teamAId : match.teamBId
|
||||
|
||||
if (!ownTeamId) {
|
||||
return NextResponse.json({ error: 'Team-ID fehlt' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ownTeam = await prisma.team.findUnique({ where: { id: ownTeamId } })
|
||||
const allowed = new Set(ownTeam?.activePlayers || [])
|
||||
|
||||
const invalid = players.some((p: any) =>
|
||||
p.teamId === ownTeamId && !allowed.has(p.userId)
|
||||
)
|
||||
|
||||
if (invalid) {
|
||||
return NextResponse.json({ error: 'Ungültige Spielerzuweisung' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// ❌ Alte Spieler löschen
|
||||
await prisma.matchPlayer.deleteMany({ where: { matchId: id } }) // ✅ Richtig, nur wenn das Feld korrekt heißt
|
||||
|
||||
// ✅ Neue Spieler speichern
|
||||
await prisma.matchPlayer.createMany({
|
||||
data: players.map((p: any) => ({
|
||||
matchId: id,
|
||||
userId: p.userId,
|
||||
teamId: p.teamId,
|
||||
})),
|
||||
})
|
||||
|
||||
// ✏️ Match aktualisieren
|
||||
const updated = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
players: {
|
||||
include: {
|
||||
user: true,
|
||||
stats: true,
|
||||
team: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })
|
||||
}
|
||||
|
||||
// 🔄 Spieler wieder trennen
|
||||
const playersA = updated.players
|
||||
.filter(p => p.teamId === updated.teamAId)
|
||||
.map(p => ({
|
||||
user: p.user,
|
||||
stats: p.stats,
|
||||
team: p.team?.name ?? 'CT',
|
||||
}))
|
||||
|
||||
const playersB = updated.players
|
||||
.filter(p => p.teamId === updated.teamBId)
|
||||
.map(p => ({
|
||||
user: p.user,
|
||||
stats: p.stats,
|
||||
team: p.team?.name ?? 'T',
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description,
|
||||
demoDate: updated.demoDate,
|
||||
matchType: updated.matchType,
|
||||
map: updated.map,
|
||||
scoreA: updated.scoreA,
|
||||
scoreB: updated.scoreB,
|
||||
teamA: playersA,
|
||||
teamB: playersB,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`PUT /matches/${id} failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, context: { params: { id: string } }) {
|
||||
const { id } = context.params
|
||||
const session = await getServerSession(authOptions(req))
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Lösche Match inklusive aller zugehörigen MatchPlayer-Einträge (wenn onDelete: Cascade nicht aktiv)
|
||||
await prisma.matchPlayer.deleteMany({ where: { matchId: id } })
|
||||
|
||||
// Lösche das Match
|
||||
await prisma.match.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error(`DELETE /matches/${id} failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to delete match' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
149
src/app/api/matches/[matchId]/_builders.ts
Normal file
149
src/app/api/matches/[matchId]/_builders.ts
Normal file
@ -0,0 +1,149 @@
|
||||
// /app/api/matches/[id]/_builders.ts
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
|
||||
/** Klein, konsistent, Frontend-freundlich */
|
||||
export function toPlayerVM(u: any) {
|
||||
return {
|
||||
steamId : u.steamId,
|
||||
name : u.name ?? 'Unbekannt',
|
||||
avatar : u.avatar ?? '/assets/img/avatars/default.png',
|
||||
location : u.location ?? null,
|
||||
premierRank: u.premierRank ?? null,
|
||||
isAdmin : u.isAdmin ?? false,
|
||||
createdAt : u.createdAt ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const mapMP = (p: any) => ({
|
||||
user : toPlayerVM(p.user),
|
||||
stats: p.stats ?? null,
|
||||
})
|
||||
|
||||
/** Startzeit (matchDate || demoDate || createdAt) als Timestamp */
|
||||
export function computeStartTs(m: any) {
|
||||
const base = m.matchDate ?? m.demoDate ?? m.createdAt
|
||||
return new Date(base).getTime()
|
||||
}
|
||||
|
||||
export function isFuture(m: any) {
|
||||
return computeStartTs(m) > Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* COMMUNITY + ZUKUNFT:
|
||||
* – zeige den ganzen Roster aus teamAUsers / teamBUsers (ohne Stats),
|
||||
* damit man die Aufstellung bearbeiten kann.
|
||||
*/
|
||||
export async function buildCommunityFuturePayload(m: any) {
|
||||
const aIds = Array.from(new Set(m.teamAUsers.map((u: any) => u.steamId)))
|
||||
const bIds = Array.from(new Set(m.teamBUsers.map((u: any) => u.steamId)))
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: { steamId: { in: [...aIds, ...bIds] } },
|
||||
})
|
||||
const byId = new Map(users.map(u => [u.steamId, toPlayerVM(u)]))
|
||||
|
||||
const teamAPlayers = aIds
|
||||
.map(id => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.map(u => ({ user: u, stats: null }))
|
||||
.sort((a: any, b: any) => (a.user.name || '').localeCompare(b.user.name || ''))
|
||||
|
||||
const teamBPlayers = bIds
|
||||
.map(id => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.map(u => ({ user: u, stats: null }))
|
||||
.sort((a: any, b: any) => (a.user.name || '').localeCompare(b.user.name || ''))
|
||||
|
||||
const startTs = computeStartTs(m)
|
||||
const editableUntil = startTs - 60 * 60 * 1000 // 1h vor Start/Veto
|
||||
|
||||
return {
|
||||
id : m.id,
|
||||
title : m.title,
|
||||
description: m.description ?? null,
|
||||
matchType : m.matchType,
|
||||
map : m.map ?? null,
|
||||
roundCount: m.roundCount ?? null,
|
||||
scoreA : m.scoreA ?? null,
|
||||
scoreB : m.scoreB ?? null,
|
||||
demoDate : m.demoDate ?? null,
|
||||
matchDate : (m.matchDate ?? m.demoDate ?? m.createdAt)?.toISOString?.() ?? null,
|
||||
editableUntil,
|
||||
|
||||
teamA: {
|
||||
id : m.teamA?.id ?? null,
|
||||
name : m.teamA?.name ?? m.teamAUsers[0]?.team?.name ?? 'Team A',
|
||||
logo : m.teamA?.logo ?? m.teamAUsers[0]?.team?.logo ?? null,
|
||||
score : m.scoreA ?? null,
|
||||
leader: m.teamA?.leader ? toPlayerVM(m.teamA.leader) : undefined,
|
||||
players: teamAPlayers,
|
||||
},
|
||||
teamB: {
|
||||
id : m.teamB?.id ?? null,
|
||||
name : m.teamB?.name ?? m.teamBUsers[0]?.team?.name ?? 'Team B',
|
||||
logo : m.teamB?.logo ?? m.teamBUsers[0]?.team?.logo ?? null,
|
||||
score : m.scoreB ?? null,
|
||||
leader: m.teamB?.leader ? toPlayerVM(m.teamB.leader) : undefined,
|
||||
players: teamBPlayers,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DEFAULT (premier/competitive oder beendetes community match):
|
||||
* – zeige nur die Spieler, die dem Match zugeordnet sind (über teamAUsers/BUsers gematcht).
|
||||
* – Stats bleiben erhalten (kommen aus m.players.include.stats).
|
||||
*/
|
||||
export function buildDefaultPayload(m: any) {
|
||||
const teamAIds = new Set(m.teamAUsers.map((u: any) => u.steamId))
|
||||
const teamBIds = new Set(m.teamBUsers.map((u: any) => u.steamId))
|
||||
|
||||
const playersA = (m.players ?? [])
|
||||
.filter((p: any) => teamAIds.has(p.steamId))
|
||||
.map((p: any) => ({
|
||||
user: toPlayerVM(p.user),
|
||||
stats: p.stats ?? null,
|
||||
team: p.team?.name ?? 'Team A',
|
||||
}))
|
||||
|
||||
const playersB = (m.players ?? [])
|
||||
.filter((p: any) => teamBIds.has(p.steamId))
|
||||
.map((p: any) => ({
|
||||
user: toPlayerVM(p.user),
|
||||
stats: p.stats ?? null,
|
||||
team: p.team?.name ?? 'Team B',
|
||||
}))
|
||||
|
||||
const matchDate = m.demoDate ?? m.matchDate ?? m.createdAt
|
||||
|
||||
return {
|
||||
id : m.id,
|
||||
title : m.title,
|
||||
description: m.description ?? null,
|
||||
matchType : m.matchType,
|
||||
map : m.map ?? null,
|
||||
roundCount: m.roundCount ?? null,
|
||||
scoreA : m.scoreA ?? null,
|
||||
scoreB : m.scoreB ?? null,
|
||||
demoDate : m.demoDate ?? null,
|
||||
matchDate : matchDate?.toISOString?.() ?? null,
|
||||
|
||||
teamA: {
|
||||
id : m.teamA?.id ?? null,
|
||||
name : m.teamA?.name ?? m.teamAUsers[0]?.team?.name ?? 'Team A',
|
||||
logo : m.teamA?.logo ?? m.teamAUsers[0]?.team?.logo ?? null,
|
||||
score : m.scoreA ?? null,
|
||||
leader: m.teamA?.leader ? toPlayerVM(m.teamA.leader) : undefined,
|
||||
players: playersA,
|
||||
},
|
||||
teamB: {
|
||||
id : m.teamB?.id ?? null,
|
||||
name : m.teamB?.name ?? m.teamBUsers[0]?.team?.name ?? 'Team B',
|
||||
logo : m.teamB?.logo ?? m.teamBUsers[0]?.team?.logo ?? null,
|
||||
score : m.scoreB ?? null,
|
||||
leader: m.teamB?.leader ? toPlayerVM(m.teamB.leader) : undefined,
|
||||
players: playersB,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,10 @@
|
||||
// /app/api/matches/[id]/delete/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { 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)
|
||||
}
|
||||
|
||||
104
src/app/api/matches/[matchId]/mapvote/reset/route.ts
Normal file
104
src/app/api/matches/[matchId]/mapvote/reset/route.ts
Normal file
@ -0,0 +1,104 @@
|
||||
// /app/api/matches/[matchId]/mapvote/reset/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/app/lib/auth'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { MapVetoAction } from '@/generated/prisma'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
|
||||
/** gleicher Pool wie in deiner mapvote-Route */
|
||||
const ACTIVE_DUTY: string[] = [
|
||||
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
|
||||
]
|
||||
|
||||
/** identische Logik wie in deiner mapvote-Route */
|
||||
function vetoOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
|
||||
const base = match.matchDate ?? match.demoDate ?? new Date()
|
||||
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
|
||||
}
|
||||
|
||||
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
|
||||
if (bestOf === 3) {
|
||||
return [
|
||||
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
|
||||
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
|
||||
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
|
||||
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
|
||||
{ order: 4, action: MapVetoAction.BAN, teamId: teamAId },
|
||||
{ order: 5, action: MapVetoAction.BAN, teamId: teamBId },
|
||||
{ order: 6, action: MapVetoAction.DECIDER, teamId: null },
|
||||
] as const
|
||||
}
|
||||
// BO5
|
||||
return [
|
||||
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
|
||||
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
|
||||
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
|
||||
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
|
||||
{ order: 4, action: MapVetoAction.PICK, teamId: teamAId },
|
||||
{ order: 5, action: MapVetoAction.PICK, teamId: teamBId },
|
||||
{ order: 6, action: MapVetoAction.PICK, teamId: teamAId },
|
||||
] as const
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||
const session = await getServerSession(authOptions(req))
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({ message: 'Nicht autorisiert' }, { status: 403 })
|
||||
}
|
||||
|
||||
const matchId = params.matchId
|
||||
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
|
||||
|
||||
// Match laden (inkl. Teams & BestOf für Steps)
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
select: {
|
||||
id: true,
|
||||
bestOf: true,
|
||||
matchDate: true,
|
||||
demoDate: true,
|
||||
teamA: { select: { id: true } },
|
||||
teamB: { select: { id: true } },
|
||||
mapVeto: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
if (!match || !match.teamA?.id || !match.teamB?.id) {
|
||||
return NextResponse.json({ message: 'Match/Teams nicht gefunden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const bestOf = match.bestOf ?? 3
|
||||
const stepsDef = buildSteps(bestOf, match.teamA.id, match.teamB.id)
|
||||
const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
|
||||
|
||||
// Reset in einer TX: alte Steps -> löschen, MapVeto -> löschen, neu anlegen
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (match.mapVeto?.id) {
|
||||
await tx.mapVetoStep.deleteMany({ where: { vetoId: match.mapVeto.id } })
|
||||
await tx.mapVeto.delete({ where: { matchId } })
|
||||
}
|
||||
|
||||
await tx.mapVeto.create({
|
||||
data: {
|
||||
matchId,
|
||||
bestOf,
|
||||
mapPool: ACTIVE_DUTY,
|
||||
currentIdx: 0,
|
||||
locked: false,
|
||||
opensAt,
|
||||
steps: {
|
||||
create: stepsDef.map(s => ({
|
||||
order: s.order,
|
||||
action: s.action,
|
||||
teamId: s.teamId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// 🔔 UI-Refresh für alle Clients
|
||||
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// /app/api/matches/[id]/map-vote/route.ts
|
||||
// /app/api/matches/[id]/mapvote/route.ts
|
||||
import { NextResponse, NextRequest } from 'next/server'
|
||||
import { 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 } = {}
|
||||
113
src/app/api/matches/[matchId]/meta/route.ts
Normal file
113
src/app/api/matches/[matchId]/meta/route.ts
Normal file
@ -0,0 +1,113 @@
|
||||
// /app/api/matches/[id]/meta/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/app/lib/auth'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||
const id = params.matchId
|
||||
const session = await getServerSession(authOptions(req))
|
||||
const me = session?.user
|
||||
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const { title, matchType, teamAId, teamBId, matchDate, map, vetoLeadMinutes } = body ?? {}
|
||||
|
||||
try {
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teamA: { include: { leader: true } },
|
||||
teamB: { include: { leader: true } },
|
||||
mapVeto: true,
|
||||
},
|
||||
})
|
||||
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||
|
||||
const isAdmin = !!me.isAdmin
|
||||
const isLeaderA = !!match.teamAId && match.teamA?.leader?.steamId === me.steamId
|
||||
const isLeaderB = !!match.teamBId && match.teamB?.leader?.steamId === me.steamId
|
||||
if (!isAdmin && !isLeaderA && !isLeaderB) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (typeof title !== 'undefined') updateData.title = title
|
||||
if (typeof matchType === 'string') updateData.matchType = matchType
|
||||
if (typeof map !== 'undefined') updateData.map = map
|
||||
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
|
||||
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
|
||||
if (typeof matchDate === 'string' || matchDate === null) {
|
||||
updateData.matchDate = matchDate ? new Date(matchDate) : null
|
||||
}
|
||||
|
||||
const lead = Number.isFinite(Number(vetoLeadMinutes)) ? Number(vetoLeadMinutes) : 60
|
||||
let opensAt: Date | null = null
|
||||
if (updateData.matchDate instanceof Date) {
|
||||
opensAt = new Date(updateData.matchDate.getTime() - lead * 60 * 1000)
|
||||
} else if (match.matchDate) {
|
||||
opensAt = new Date(match.matchDate.getTime() - lead * 60 * 1000)
|
||||
}
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const m = await tx.match.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { mapVeto: true },
|
||||
})
|
||||
|
||||
if (opensAt) {
|
||||
if (!m.mapVeto) {
|
||||
await tx.mapVeto.create({
|
||||
data: {
|
||||
matchId: m.id,
|
||||
opensAt,
|
||||
// entferne Felder, die es im Schema nicht gibt (z. B. isOpen)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await tx.mapVeto.update({
|
||||
where: { id: m.mapVeto.id },
|
||||
data: { opensAt },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tx.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teamA: { include: { leader: true } },
|
||||
teamB: { include: { leader: true } },
|
||||
mapVeto: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'match-updated',
|
||||
payload: { matchId: updated.id, updatedAt: new Date().toISOString() },
|
||||
})
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'map-vote-updated',
|
||||
payload: { matchId: updated.id, opensAt: updated.mapVeto?.opensAt ?? null },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
matchType: updated.matchType,
|
||||
teamAId: updated.teamAId,
|
||||
teamBId: updated.teamBId,
|
||||
matchDate: updated.matchDate,
|
||||
map: updated.map,
|
||||
mapVeto: updated.mapVeto,
|
||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||
} catch (err) {
|
||||
console.error(`PUT /matches/${id}/meta failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to update match meta' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
251
src/app/api/matches/[matchId]/route.ts
Normal file
251
src/app/api/matches/[matchId]/route.ts
Normal file
@ -0,0 +1,251 @@
|
||||
// /app/api/matches/[id]/route.ts
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/app/lib/auth'
|
||||
import {
|
||||
isFuture,
|
||||
buildCommunityFuturePayload,
|
||||
buildDefaultPayload,
|
||||
} from './_builders'
|
||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||
|
||||
/* ───────────────────────── GET ───────────────────────── */
|
||||
export async function GET(_: Request, { params }: { params: { matchId: string } }) {
|
||||
const id = params.matchId
|
||||
if (!id) return NextResponse.json({ error: 'Missing ID' }, { status: 400 })
|
||||
|
||||
try {
|
||||
const m = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teamA: { include: { leader: true } },
|
||||
teamB: { include: { leader: true } },
|
||||
players: { include: { user: true, stats: true, team: true } },
|
||||
teamAUsers: { include: { team: true } },
|
||||
teamBUsers: { include: { team: true } },
|
||||
},
|
||||
})
|
||||
if (!m) return NextResponse.json({ error: 'Match nicht gefunden' }, { status: 404 })
|
||||
|
||||
const payload =
|
||||
m.matchType === 'community' && isFuture(m)
|
||||
? await buildCommunityFuturePayload(m)
|
||||
: buildDefaultPayload(m)
|
||||
|
||||
return NextResponse.json(payload, { headers: { 'Cache-Control': 'no-store' } })
|
||||
} catch (err) {
|
||||
console.error(`GET /matches/${params.matchId} failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to load match' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── PUT ───────────────────────── */
|
||||
export async function PUT(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||
const id = params.matchId
|
||||
const session = await getServerSession(authOptions(req))
|
||||
const me = session?.user
|
||||
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
|
||||
// Erwartet: players: Array<{ steamId: string; teamId: string | null }>
|
||||
const body = await req.json()
|
||||
const input = (body?.players ?? []) as Array<{ steamId?: string; teamId?: string | null }>
|
||||
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teamA: { include: { leader: true } },
|
||||
teamB: { include: { leader: true } },
|
||||
teamAUsers: true,
|
||||
teamBUsers: true,
|
||||
},
|
||||
})
|
||||
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||
|
||||
// Eingaben säubern + deduplizieren
|
||||
const bySteam = new Map<string, { steamId: string; teamId: string | null }>()
|
||||
for (const p of input) {
|
||||
if (typeof p?.steamId === 'string' && p.steamId.trim()) {
|
||||
bySteam.set(p.steamId.trim(), { steamId: p.steamId.trim(), teamId: p.teamId ?? null })
|
||||
}
|
||||
}
|
||||
const unique = Array.from(bySteam.values())
|
||||
|
||||
const isAdmin = !!me.isAdmin
|
||||
const isLeaderA = !!match.teamAId && match.teamA?.leader?.steamId === me.steamId
|
||||
const isLeaderB = !!match.teamBId && match.teamB?.leader?.steamId === me.steamId
|
||||
|
||||
// Community-Roster-Mitgliedschaft
|
||||
const rosterA = new Set(match.teamAUsers.map(u => u.steamId))
|
||||
const rosterB = new Set(match.teamBUsers.map(u => u.steamId))
|
||||
const isInA = rosterA.has(me.steamId)
|
||||
const isInB = rosterB.has(me.steamId)
|
||||
|
||||
// Welche Seite darf der User ändern?
|
||||
let editorSide: 'A' | 'B' | 'both' | null = null
|
||||
if (isAdmin) editorSide = 'both'
|
||||
else if (isLeaderA || isInA) editorSide = 'A'
|
||||
else if (isLeaderB || isInB) editorSide = 'B'
|
||||
else editorSide = null
|
||||
|
||||
if (!editorSide) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const hasTeamIds = Boolean(match.teamAId && match.teamBId)
|
||||
|
||||
// Seite zuordnen (mit/ohne echte Team-IDs)
|
||||
const incomingA = unique.filter(p => hasTeamIds ? p.teamId === match.teamAId : rosterA.has(p.steamId))
|
||||
const incomingB = unique.filter(p => hasTeamIds ? p.teamId === match.teamBId : rosterB.has(p.steamId))
|
||||
|
||||
// Validierung: nur eigene Seite prüfen
|
||||
if (!isAdmin) {
|
||||
if (editorSide === 'A') {
|
||||
const invalid = incomingA.some(p => hasTeamIds ? p.teamId !== match.teamAId : !rosterA.has(p.steamId))
|
||||
if (invalid) return NextResponse.json({ error: 'Ungültige Spielerzuweisung (A)' }, { status: 403 })
|
||||
}
|
||||
if (editorSide === 'B') {
|
||||
const invalid = incomingB.some(p => hasTeamIds ? p.teamId !== match.teamBId : !rosterB.has(p.steamId))
|
||||
if (invalid) return NextResponse.json({ error: 'Ungültige Spielerzuweisung (B)' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
// SteamId-Listen für die Relation
|
||||
const aSteamIds = Array.from(new Set(incomingA.map(p => p.steamId)))
|
||||
const bSteamIds = Array.from(new Set(incomingB.map(p => p.steamId)))
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1) teamAUsers / teamBUsers SETZEN (nur die Seite(n), die editiert werden)
|
||||
const data: any = {}
|
||||
if (editorSide === 'both' || editorSide === 'A') {
|
||||
data.teamAUsers = {
|
||||
set: [], // erst leeren
|
||||
connect: aSteamIds.map(steamId => ({ steamId })),
|
||||
}
|
||||
}
|
||||
if (editorSide === 'both' || editorSide === 'B') {
|
||||
data.teamBUsers = {
|
||||
set: [],
|
||||
connect: bSteamIds.map(steamId => ({ steamId })),
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length) {
|
||||
await tx.match.update({ where: { id }, data })
|
||||
}
|
||||
|
||||
// 2) (optional) matchPlayer für die geänderten Seiten synchron halten
|
||||
// – so bleiben Teilnehmer + spätere Stats konsistent.
|
||||
if (editorSide === 'both' || editorSide === 'A') {
|
||||
if (hasTeamIds) {
|
||||
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamAId! } })
|
||||
if (aSteamIds.length) {
|
||||
await tx.matchPlayer.createMany({
|
||||
data: aSteamIds.map(steamId => ({ matchId: id, steamId, teamId: match.teamAId! })),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Community ohne teamId → anhand Roster löschen
|
||||
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterA) } } })
|
||||
if (aSteamIds.length) {
|
||||
await tx.matchPlayer.createMany({
|
||||
data: aSteamIds.map(steamId => ({ matchId: id, steamId, teamId: null })),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (editorSide === 'both' || editorSide === 'B') {
|
||||
if (hasTeamIds) {
|
||||
await tx.matchPlayer.deleteMany({ where: { matchId: id, teamId: match.teamBId! } })
|
||||
if (bSteamIds.length) {
|
||||
await tx.matchPlayer.createMany({
|
||||
data: bSteamIds.map(steamId => ({ matchId: id, steamId, teamId: match.teamBId! })),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await tx.matchPlayer.deleteMany({ where: { matchId: id, steamId: { in: Array.from(rosterB) } } })
|
||||
if (bSteamIds.length) {
|
||||
await tx.matchPlayer.createMany({
|
||||
data: bSteamIds.map(steamId => ({ matchId: id, steamId, teamId: null })),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Antwort wie GET
|
||||
const updated = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teamA: { include: { leader: true } },
|
||||
teamB: { include: { leader: true } },
|
||||
players: { include: { user: true, stats: true, team: true } },
|
||||
teamAUsers: { include: { team: true } },
|
||||
teamBUsers: { include: { team: true } },
|
||||
},
|
||||
})
|
||||
if (!updated) return NextResponse.json({ error: 'Match konnte nach Update nicht geladen werden' }, { status: 500 })
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'match-lineup-updated',
|
||||
payload: {
|
||||
matchId: updated.id,
|
||||
teamA: updated.teamAUsers.map(u => u.steamId),
|
||||
teamB: updated.teamBUsers.map(u => u.steamId),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
// Trennung für Response (gleich wie bisher)
|
||||
const setA = new Set(updated.teamAUsers.map(u => u.steamId))
|
||||
const setB = new Set(updated.teamBUsers.map(u => u.steamId))
|
||||
const playersA = updated.players.filter(p => setA.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'Team A' }))
|
||||
const playersB = updated.players.filter(p => setB.has(p.steamId)).map(p => ({ user: p.user, stats: p.stats, team: p.team?.name ?? 'Team B' }))
|
||||
|
||||
return NextResponse.json({
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description,
|
||||
demoDate: updated.demoDate,
|
||||
matchType: updated.matchType,
|
||||
roundCount: updated.roundCount,
|
||||
map: updated.map,
|
||||
teamA: {
|
||||
id: updated.teamAId ?? null,
|
||||
name: updated.teamAUsers[0]?.team?.name ?? updated.teamA?.name ?? 'Team A',
|
||||
logo: updated.teamAUsers[0]?.team?.logo ?? updated.teamA?.logo ?? null,
|
||||
score: updated.scoreA,
|
||||
players: playersA,
|
||||
},
|
||||
teamB: {
|
||||
id: updated.teamBId ?? null,
|
||||
name: updated.teamBUsers[0]?.team?.name ?? updated.teamB?.name ?? 'Team B',
|
||||
logo: updated.teamBUsers[0]?.team?.logo ?? updated.teamB?.logo ?? null,
|
||||
score: updated.scoreB,
|
||||
players: playersB,
|
||||
},
|
||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||
} catch (err) {
|
||||
console.error(`PUT /matches/${id} failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to update match' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ───────────────────────── DELETE ───────────────────────── */
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { matchId: string } }) {
|
||||
const id = params.matchId
|
||||
const session = await getServerSession(authOptions(req))
|
||||
if (!session?.user?.isAdmin) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
try {
|
||||
await prisma.matchPlayer.deleteMany({ where: { matchId: id } })
|
||||
await prisma.match.delete({ where: { id } })
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error(`DELETE /matches/${id} failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to delete match' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,109 +0,0 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { baseAuthOptions } from '@/app/lib/auth'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { InvitedPlayer } from '@/app/types/team'
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(baseAuthOptions)
|
||||
|
||||
if (!session?.user?.steamId) {
|
||||
return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ leader: { steamId: session.user.steamId } },
|
||||
{ activePlayers: { has: session.user.steamId } },
|
||||
{ inactivePlayers: { has: session.user.steamId } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
leader: true,
|
||||
matchesAsTeamA: {
|
||||
include: {
|
||||
teamA: true,
|
||||
teamB: true,
|
||||
},
|
||||
},
|
||||
matchesAsTeamB: {
|
||||
include: {
|
||||
teamA: true,
|
||||
teamB: true,
|
||||
},
|
||||
},
|
||||
invites: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ team: null }, { status: 200 })
|
||||
}
|
||||
|
||||
const steamIds = [...team.activePlayers, ...team.inactivePlayers]
|
||||
|
||||
const playerData = await prisma.user.findMany({
|
||||
where: { steamId: { in: steamIds } },
|
||||
select: {
|
||||
steamId: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
location: true,
|
||||
premierRank: true,
|
||||
},
|
||||
})
|
||||
|
||||
const activePlayers = team.activePlayers
|
||||
.map((id) => playerData.find((m) => m.steamId === id))
|
||||
.filter(Boolean)
|
||||
|
||||
const inactivePlayers = team.inactivePlayers
|
||||
.map((id) => playerData.find((m) => m.steamId === id))
|
||||
.filter(Boolean)
|
||||
|
||||
const invitedPlayers: InvitedPlayer[] = Array.from(
|
||||
new Map(
|
||||
team.invites.map(invite => {
|
||||
const u = invite.user
|
||||
return [
|
||||
u.steamId,
|
||||
{
|
||||
steamId: u.steamId,
|
||||
name: u.name ?? 'Unbekannt',
|
||||
avatar: u.avatar ?? '/assets/img/avatars/default.png',
|
||||
location: u.location ?? '',
|
||||
premierRank: u.premierRank ?? 0,
|
||||
invitationId: invite.id,
|
||||
}
|
||||
]
|
||||
})
|
||||
).values()
|
||||
).sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const matches = [...team.matchesAsTeamA, ...team.matchesAsTeamB]
|
||||
.filter(m => m.teamA && m.teamB)
|
||||
.sort((a, b) => new Date(a.matchDate).getTime() - new Date(b.matchDate).getTime())
|
||||
|
||||
return NextResponse.json({
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
logo: team.logo,
|
||||
leader: team.leader?.steamId ?? null,
|
||||
activePlayers,
|
||||
inactivePlayers,
|
||||
invitedPlayers,
|
||||
matches,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Fehler in /api/team:', error)
|
||||
return NextResponse.json({ error: 'Interner Fehler beim Laden des Teams' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
62
src/app/components/Alert.tsx
Normal file
62
src/app/components/Alert.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type AlertProps = {
|
||||
type?: 'solid' | 'soft'
|
||||
color?:
|
||||
| 'dark'
|
||||
| 'secondary'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'danger'
|
||||
| 'warning'
|
||||
| 'light'
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Alert({
|
||||
type = 'solid',
|
||||
color = 'info',
|
||||
children,
|
||||
className,
|
||||
}: AlertProps) {
|
||||
const baseClasses =
|
||||
'mt-2 text-sm rounded-lg p-2'
|
||||
const variantClasses: Record<string, Record<string, string>> = {
|
||||
solid: {
|
||||
dark: 'bg-gray-800 text-white dark:bg-white dark:text-neutral-800',
|
||||
secondary: 'bg-gray-500 text-white',
|
||||
info: 'bg-blue-600 text-white dark:bg-blue-500',
|
||||
success: 'bg-teal-500 text-white',
|
||||
danger: 'bg-red-500 text-white',
|
||||
warning: 'bg-yellow-500 text-white',
|
||||
light: 'bg-white text-gray-600',
|
||||
},
|
||||
soft: {
|
||||
dark: 'bg-gray-100 border border-gray-200 text-gray-800 dark:bg-white/10 dark:border-white/20 dark:text-white',
|
||||
secondary: 'bg-gray-50 border border-gray-200 text-gray-600 dark:bg-white/10 dark:border-white/10 dark:text-neutral-400',
|
||||
info: 'bg-blue-100 border border-blue-200 text-blue-800 dark:bg-blue-800/10 dark:border-blue-900 dark:text-blue-500',
|
||||
success: 'bg-teal-100 border border-teal-200 text-teal-800 dark:bg-teal-800/10 dark:border-teal-900 dark:text-teal-500',
|
||||
danger: 'bg-red-100 border border-red-200 text-red-800 dark:bg-red-800/10 dark:border-red-900 dark:text-red-500',
|
||||
warning: 'bg-yellow-100 border border-yellow-200 text-yellow-800 dark:bg-yellow-800/10 dark:border-yellow-900 dark:text-yellow-500',
|
||||
light: 'bg-white/10 border border-white/10 text-white',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className={clsx(
|
||||
baseClasses,
|
||||
variantClasses[type][color],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
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<HTMLButtonElement>
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
@ -28,7 +28,8 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
size = 'md',
|
||||
className,
|
||||
dropDirection = "down",
|
||||
disabled = false
|
||||
disabled = false,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@ -147,6 +148,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
className={classes}
|
||||
onClick={toggle}
|
||||
{...modalAttributes}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? title}
|
||||
</button>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
// Dropdown.tsx
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type DropdownItem = {
|
||||
|
||||
@ -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 */}
|
||||
<div ref={setNodeRef} className={zoneClasses}>
|
||||
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
||||
<div className="grid gap-4 justify-start grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
273
src/app/components/EditMatchMetaModal.tsx
Normal file
273
src/app/components/EditMatchMetaModal.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
// app/components/EditMatchMetaModal.tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Modal from '@/app/components/Modal'
|
||||
import Alert from '@/app/components/Alert'
|
||||
import Select from '@/app/components/Select'
|
||||
import { MAP_OPTIONS } from '../lib/mapOptions'
|
||||
|
||||
type TeamOption = { id: string; name: string; logo?: string | null }
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
matchId: string
|
||||
defaultTitle?: string | null
|
||||
defaultTeamAId?: string | null
|
||||
defaultTeamBId?: string | null
|
||||
defaultTeamAName?: string | null
|
||||
defaultTeamBName?: string | null
|
||||
defaultDateISO?: string | null
|
||||
defaultMap?: string | null
|
||||
defaultVetoLeadMinutes?: number
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
export default function EditMatchMetaModal({
|
||||
show,
|
||||
onClose,
|
||||
matchId,
|
||||
defaultTitle = '',
|
||||
defaultTeamAId,
|
||||
defaultTeamBId,
|
||||
defaultTeamAName,
|
||||
defaultTeamBName,
|
||||
defaultDateISO,
|
||||
defaultMap,
|
||||
defaultVetoLeadMinutes = 60,
|
||||
onSaved,
|
||||
}: Props) {
|
||||
// -------- state
|
||||
const [title, setTitle] = useState(defaultTitle ?? '')
|
||||
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
||||
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
||||
const [date, setDate] = useState<string>(() => {
|
||||
if (!defaultDateISO) return ''
|
||||
const d = new Date(defaultDateISO)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
})
|
||||
const [mapKey, setMapKey] = useState<string>(defaultMap ?? 'lobby_mapveto')
|
||||
const [vetoLead, setVetoLead] = useState<number>(defaultVetoLeadMinutes)
|
||||
|
||||
const [teams, setTeams] = useState<TeamOption[]>([])
|
||||
const [loadingTeams, setLoadingTeams] = useState(false)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// -------- load teams when open
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
setLoadingTeams(true)
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`Team-API: ${res.status}`)
|
||||
const data = (await res.json()) as TeamOption[]
|
||||
setTeams((Array.isArray(data) ? data : []).filter(t => t?.id && t?.name))
|
||||
} catch (e) {
|
||||
console.error('[EditMatchMetaModal] load teams failed:', e)
|
||||
setTeams([])
|
||||
} finally {
|
||||
setLoadingTeams(false)
|
||||
}
|
||||
})()
|
||||
}, [show])
|
||||
|
||||
// -------- reset defaults on open
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
setTitle(defaultTitle ?? '')
|
||||
setTeamAId(defaultTeamAId ?? '')
|
||||
setTeamBId(defaultTeamBId ?? '')
|
||||
setMapKey(defaultMap ?? 'lobby_mapveto')
|
||||
setVetoLead(defaultVetoLeadMinutes)
|
||||
if (defaultDateISO) {
|
||||
const d = new Date(defaultDateISO)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
setDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`)
|
||||
} else {
|
||||
setDate('')
|
||||
}
|
||||
setSaved(false)
|
||||
setError(null)
|
||||
}, [
|
||||
show,
|
||||
defaultTitle,
|
||||
defaultTeamAId,
|
||||
defaultTeamBId,
|
||||
defaultDateISO,
|
||||
defaultMap,
|
||||
defaultVetoLeadMinutes,
|
||||
])
|
||||
|
||||
// -------- derived: options
|
||||
const teamOptionsA = useMemo(() => {
|
||||
// Team B nicht in A auswählbar machen
|
||||
return teams
|
||||
.filter(t => t.id !== teamBId)
|
||||
.map(t => ({ value: t.id, label: t.name }));
|
||||
}, [teams, teamBId]);
|
||||
|
||||
const teamOptionsB = useMemo(() => {
|
||||
// Team A nicht in B auswählbar machen
|
||||
return teams
|
||||
.filter(t => t.id !== teamAId)
|
||||
.map(t => ({ value: t.id, label: t.name }));
|
||||
}, [teams, teamAId]);
|
||||
|
||||
const mapOptions = useMemo(
|
||||
() => MAP_OPTIONS.map(m => ({ value: m.key, label: m.label })),
|
||||
[]
|
||||
)
|
||||
|
||||
// -------- validation
|
||||
const canSave = useMemo(() => {
|
||||
if (saving) return false
|
||||
if (!date) return false
|
||||
if (teamAId && teamBId && teamAId === teamBId) return false
|
||||
return true
|
||||
}, [saving, date, teamAId, teamBId])
|
||||
|
||||
// -------- save
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const body = {
|
||||
title: title || null,
|
||||
teamAId: teamAId || null,
|
||||
teamBId: teamBId || null,
|
||||
matchDate: date ? new Date(date).toISOString() : null,
|
||||
map: mapKey || null,
|
||||
vetoLeadMinutes: Number.isFinite(Number(vetoLead)) ? Number(vetoLead) : 60,
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/matches/${matchId}/meta`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}))
|
||||
throw new Error(j?.error || 'Update fehlgeschlagen')
|
||||
}
|
||||
|
||||
setSaved(true)
|
||||
onSaved?.()
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
console.error('[EditMatchMetaModal] save error:', e)
|
||||
setError(e?.message || 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!show) return null
|
||||
|
||||
// Platzhalter mit aktuellem Namen (falls Options noch laden)
|
||||
const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …')
|
||||
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …')
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id="edit-match-meta"
|
||||
title="Matchdaten bearbeiten"
|
||||
show
|
||||
onClose={onClose}
|
||||
onSave={handleSave}
|
||||
closeButtonTitle={saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'}
|
||||
closeButtonColor={saved ? 'green' : 'blue'}
|
||||
disableSave={!canSave}
|
||||
maxWidth="sm:max-w-2xl"
|
||||
>
|
||||
{error && (
|
||||
<Alert type="soft" color="danger" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Titel */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Titel</label>
|
||||
<input
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="z.B. Scrim vs. XYZ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team A */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Team A</label>
|
||||
<Select
|
||||
options={teamOptionsA}
|
||||
value={teamAId}
|
||||
onChange={setTeamAId}
|
||||
placeholder={teamAPlaceholder}
|
||||
dropDirection="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team B */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Team B</label>
|
||||
<Select
|
||||
options={teamOptionsB}
|
||||
value={teamBId}
|
||||
onChange={setTeamBId}
|
||||
placeholder={teamBPlaceholder}
|
||||
dropDirection="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Datum/Uhrzeit */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Datum & Uhrzeit</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<label className="block text-sm font-medium mb-1">Map</label>
|
||||
<Select
|
||||
options={mapOptions}
|
||||
value={mapKey}
|
||||
onChange={setMapKey}
|
||||
placeholder="Map wählen …"
|
||||
dropDirection="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Veto-Lead */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Map-Veto lead (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={vetoLead}
|
||||
onChange={e => setVetoLead(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Zeit vor Matchstart, zu der das Veto öffnet (Standard 60).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -72,54 +72,53 @@ export default function EditMatchPlayersModal (props: Props) {
|
||||
/* ---- Komplett-Spielerliste laden ------------------------ */
|
||||
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 = [
|
||||
// 👉 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<string, Player>()
|
||||
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 ------------- */}
|
||||
<DroppableZone
|
||||
id="active"
|
||||
className="mb-4"
|
||||
label={`Eingesetzte Spieler (${active.length} / 5)`}
|
||||
activeDragItem={dragItem}
|
||||
>
|
||||
|
||||
@ -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<MapVetoState | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 ' +
|
||||
|
||||
@ -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()
|
||||
@ -43,30 +45,27 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
// --- 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 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) => {
|
||||
const canActForTeamId = useCallback(
|
||||
(teamId?: string | null) => {
|
||||
if (!teamId) return false
|
||||
if (isAdmin) return true
|
||||
return (teamId === match.teamA?.id && isLeaderA) ||
|
||||
return (
|
||||
(teamId === match.teamA?.id && isLeaderA) ||
|
||||
(teamId === match.teamB?.id && isLeaderB)
|
||||
}, [isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id])
|
||||
)
|
||||
},
|
||||
[isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id],
|
||||
)
|
||||
|
||||
// --- Laden / Reload ---
|
||||
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 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<string, { action: 'ban'|'pick'|'decider'; teamId: string | null }>()
|
||||
for (const s of (state?.steps ?? [])) {
|
||||
const map = new Map<string, { action: 'ban' | 'pick' | 'decider'; teamId: string | null }>()
|
||||
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<number | null>(null)
|
||||
const holdStartRef = useRef<number | null>(null)
|
||||
const holdMapRef = useRef<string | null>(null)
|
||||
const submittedRef = useRef<boolean>(false) // gegen Doppel-Submit
|
||||
const submittedRef = useRef<boolean>(false)
|
||||
const [progressByMap, setProgressByMap] = useState<Record<string, number>>({})
|
||||
|
||||
const resetHold = useCallback(() => {
|
||||
@ -153,19 +169,23 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
submittedRef.current = false
|
||||
}, [])
|
||||
|
||||
const finishAndSubmit = useCallback((map: string) => {
|
||||
const finishAndSubmit = useCallback(
|
||||
(map: string) => {
|
||||
if (submittedRef.current) return
|
||||
submittedRef.current = true
|
||||
setTimeout(() => handlePickOrBan(map), 10)
|
||||
}, [handlePickOrBan])
|
||||
},
|
||||
[handlePickOrBan],
|
||||
)
|
||||
|
||||
const stepHold = useCallback((ts: number) => {
|
||||
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
|
||||
@ -174,18 +194,24 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
return
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(stepHold)
|
||||
}, [resetHold, finishAndSubmit])
|
||||
},
|
||||
[resetHold, finishAndSubmit],
|
||||
)
|
||||
|
||||
const onHoldStart = useCallback((map: string, allowed: boolean) => {
|
||||
const onHoldStart = useCallback(
|
||||
(map: string, allowed: boolean) => {
|
||||
if (!allowed) return
|
||||
resetHold()
|
||||
holdMapRef.current = map
|
||||
holdStartRef.current = performance.now()
|
||||
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
|
||||
setProgressByMap((prev) => ({ ...prev, [map]: 0 }))
|
||||
rafRef.current = requestAnimationFrame(stepHold)
|
||||
}, [stepHold, resetHold])
|
||||
},
|
||||
[stepHold, resetHold],
|
||||
)
|
||||
|
||||
const cancelOrSubmitIfComplete = useCallback((map: string) => {
|
||||
const cancelOrSubmitIfComplete = useCallback(
|
||||
(map: string) => {
|
||||
const p = progressByMap[map] ?? 0
|
||||
if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) {
|
||||
resetHold()
|
||||
@ -194,9 +220,22 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
}
|
||||
if (holdMapRef.current === map) {
|
||||
resetHold()
|
||||
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
|
||||
setProgressByMap((prev) => ({ ...prev, [map]: 0 }))
|
||||
}
|
||||
}, [progressByMap, resetHold, finishAndSubmit])
|
||||
},
|
||||
[progressByMap, resetHold, finishAndSubmit],
|
||||
)
|
||||
|
||||
const deciderChooserTeamId = useMemo(() => {
|
||||
const steps = state?.steps ?? []
|
||||
const decIdx = steps.findIndex(s => s.action === 'decider')
|
||||
if (decIdx < 0) return null
|
||||
for (let i = decIdx - 1; i >= 0; i--) {
|
||||
const s = steps[i]
|
||||
if (s.action === 'ban' && s.teamId) return s.teamId
|
||||
}
|
||||
return null
|
||||
}, [state?.steps])
|
||||
|
||||
// Touch-Unterstützung
|
||||
const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => {
|
||||
@ -208,18 +247,125 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
cancelOrSubmitIfComplete(map)
|
||||
}
|
||||
|
||||
if (isLoading && !state) return <div className="p-4">Lade Map-Voting…</div>
|
||||
if (error && !state) return <div className="p-4 text-red-600">{error}</div>
|
||||
// --- Spielerlisten ableiten (Hooks bleiben IMMER aktiv) ---
|
||||
const playersA = useMemo<MatchPlayer[]>(() => {
|
||||
// 0) Bevorzugt: bereits vorbereitete Team-Spieler am Match selbst
|
||||
const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined
|
||||
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
|
||||
|
||||
const playersA = match.teamA.players as unknown as MatchPlayer[]
|
||||
const playersB = match.teamB.players as unknown as MatchPlayer[]
|
||||
// 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<MatchPlayer[]>(() => {
|
||||
const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined
|
||||
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
|
||||
|
||||
const all = (match as any).players as MatchPlayer[] | undefined
|
||||
const teamBUsers = (match as any).teamBUsers as { steamId: string }[] | undefined
|
||||
if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) {
|
||||
const setB = new Set(teamBUsers.map(u => u.steamId))
|
||||
return all.filter(p => setB.has(p.user.steamId))
|
||||
}
|
||||
|
||||
if (Array.isArray(all) && match.teamB?.id) {
|
||||
return all.filter(p => (p as any).team?.id === match.teamB?.id)
|
||||
}
|
||||
|
||||
const vetoPlayers = state?.teams?.teamB?.players as
|
||||
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
|
||||
| undefined
|
||||
|
||||
if (Array.isArray(vetoPlayers) && vetoPlayers.length) {
|
||||
return vetoPlayers.map((p): MatchPlayer => ({
|
||||
user: {
|
||||
steamId: p.steamId,
|
||||
name: p.name ?? 'Unbekannt',
|
||||
avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
|
||||
},
|
||||
stats: undefined,
|
||||
// team: (match as any)?.teamB?.name ?? 'Team B',
|
||||
}))
|
||||
}
|
||||
|
||||
return []
|
||||
}, [match, state?.teams?.teamB?.players])
|
||||
|
||||
const showLoading = isLoading && !state
|
||||
const showError = !!error && !state
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{showLoading ? (
|
||||
<div className="p-4">Lade Map-Voting…</div>
|
||||
) : showError ? (
|
||||
<div className="p-4 text-red-600">{error}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold">Map-Vote</h3>
|
||||
<div className="text-sm opacity-80">Modus: BO{match.bestOf ?? state?.bestOf ?? 3}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm opacity-80">
|
||||
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-3"
|
||||
title="Map-Vote zurücksetzen"
|
||||
onClick={async () => {
|
||||
if (!confirm('Map-Vote wirklich zurücksetzen? Alle bisherigen Picks/Bans gehen verloren.')) return
|
||||
try {
|
||||
const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' })
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}))
|
||||
alert(j.message ?? 'Reset fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
// SSE feuert ohnehin; zusätzlich lokal nachladen:
|
||||
await load()
|
||||
} catch {
|
||||
alert('Netzwerkfehler beim Reset')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Countdown / Status */}
|
||||
@ -231,6 +377,37 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown / Status ganz oben und größer */}
|
||||
<div className="mb-4 flex justify-center">
|
||||
{state?.locked ? (
|
||||
<span className="block text-lg font-semibold px-3 py-2 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 text-center">
|
||||
✅ Veto abgeschlossen
|
||||
</span>
|
||||
) : isOpen ? (
|
||||
isMyTurn ? (
|
||||
<span className="block text-lg font-semibold px-3 py-2 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200 text-center">
|
||||
✋ Halte gedrückt, um zu bestätigen
|
||||
</span>
|
||||
) : (
|
||||
<span className="block text-lg font-semibold px-3 py-2 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200 text-center">
|
||||
⏳ Wartet auf
|
||||
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
|
||||
(Leader/Admin)
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="block text-lg font-semibold px-3 py-2 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 text-center">
|
||||
Öffnet in {formatCountdown(msToOpen)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<span className="block mt-2 text-base font-medium px-3 py-2 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 text-center">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hauptbereich */}
|
||||
{state && (
|
||||
<div className="mt-2 flex items-start gap-4 justify-between">
|
||||
@ -244,13 +421,20 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
avatar={p.user.avatar}
|
||||
rank={p.stats?.rankNew ?? 0}
|
||||
matchType={match.matchType}
|
||||
isLeader={(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) === p.user.steamId}
|
||||
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) && !state.locked}
|
||||
isLeader={
|
||||
(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) ===
|
||||
p.user.steamId
|
||||
}
|
||||
isActiveTurn={
|
||||
!!currentStep?.teamId &&
|
||||
currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) &&
|
||||
!state.locked
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{/* Mitte – Maps untereinander (kompakt + Hold-to-confirm) */}
|
||||
{/* Mitte – Maps (Hold-to-confirm) */}
|
||||
<main className="max-w-sm flex-shrink-0">
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{mapPool.map((map) => {
|
||||
@ -261,35 +445,77 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
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 visualClasses =
|
||||
taken
|
||||
? (status === 'ban'
|
||||
|
||||
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')
|
||||
: (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')
|
||||
: 'bg-neutral-100 dark:bg-neutral-800 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 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
|
||||
|
||||
const leftLogo = getTeamLogo(match.teamA?.logo)
|
||||
const rightLogo = getTeamLogo(match.teamB?.logo)
|
||||
// Decider-Team bestimmen (falls nötig)
|
||||
const effectiveTeamId =
|
||||
status === 'decider'
|
||||
? deciderChooserTeamId
|
||||
: decision?.teamId ?? null
|
||||
|
||||
const pickedByA =
|
||||
(status === 'pick' || status === 'decider') && effectiveTeamId === match.teamA?.id
|
||||
const pickedByB =
|
||||
(status === 'pick' || status === 'decider') && effectiveTeamId === match.teamB?.id
|
||||
|
||||
const progress = progressByMap[map] ?? 0
|
||||
const showProgress = isAvailable && progress > 0 && progress < 1
|
||||
|
||||
return (
|
||||
<li key={map}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${baseClasses} ${visualClasses} w-full text-left`}
|
||||
<li
|
||||
key={map}
|
||||
className="grid grid-cols-[24px_1fr_24px] items-center gap-2"
|
||||
>
|
||||
{/* linker Slot */}
|
||||
{pickedByA ? (
|
||||
<img
|
||||
src={getTeamLogo(match.teamA?.logo)}
|
||||
alt={match.teamA?.name ?? 'Team A'}
|
||||
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6" />
|
||||
)}
|
||||
|
||||
{/* Button */}
|
||||
<Button
|
||||
className={`${baseClasses} ${visualClasses} w-full text-left relative`}
|
||||
disabled={!isAvailable}
|
||||
title={
|
||||
taken
|
||||
@ -309,31 +535,19 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
onTouchEnd={onTouchEnd(map)}
|
||||
onTouchCancel={onTouchEnd(map)}
|
||||
>
|
||||
{/* Fortschrittsbalken (unter dem Inhalt) */}
|
||||
{/* Fortschrittsbalken */}
|
||||
{showProgress && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 left-0 rounded-md bg-blue-200/60 dark:bg-blue-800/40 pointer-events-none z-0"
|
||||
className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-0`}
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Linkes Logo bei Pick durch Team A */}
|
||||
{showLeftLogo && (
|
||||
<img
|
||||
src={leftLogo}
|
||||
alt={match.teamA?.name ?? 'Team A'}
|
||||
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900 z-[1]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Inhalt mittig (Mapname + technischer Key) */}
|
||||
{/* Inhalt */}
|
||||
<div className="flex-1 min-w-0 relative z-[1] flex flex-col items-center justify-center text-center">
|
||||
<span className="text-[13px] font-medium truncate">
|
||||
{fmt(map)}
|
||||
</span>
|
||||
<span className="text-[13px] font-medium truncate">{fmt(map)}</span>
|
||||
|
||||
{/* rotes X bei Ban – über dem Namen */}
|
||||
{status === 'ban' && (
|
||||
<span
|
||||
aria-hidden
|
||||
@ -349,47 +563,22 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Rechtes Logo bei Pick durch Team B */}
|
||||
{showRightLogo && (
|
||||
{/* rechter Slot */}
|
||||
{pickedByB ? (
|
||||
<img
|
||||
src={rightLogo}
|
||||
src={getTeamLogo(match.teamB?.logo)}
|
||||
alt={match.teamB?.name ?? 'Team B'}
|
||||
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900 z-[1]"
|
||||
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Footer-Status */}
|
||||
<div className="mt-3 text-sm flex flex-wrap items-center gap-3">
|
||||
{state.locked ? (
|
||||
<span className="px-2 py-1 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
|
||||
Veto abgeschlossen
|
||||
</span>
|
||||
) : isOpen ? (
|
||||
isMyTurn ? (
|
||||
<span className="px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
Halte gedrückt, um zu bestätigen
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200">
|
||||
Wartet auf
|
||||
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
|
||||
(Leader/Admin)
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{error && (
|
||||
<span className="px-2 py-1 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Rechts – Team B */}
|
||||
@ -402,13 +591,22 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
avatar={p.user.avatar}
|
||||
rank={p.stats?.rankNew ?? 0}
|
||||
matchType={match.matchType}
|
||||
isLeader={(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === p.user.steamId}
|
||||
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) && !state.locked}
|
||||
isLeader={
|
||||
(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) ===
|
||||
p.user.steamId
|
||||
}
|
||||
isActiveTurn={
|
||||
!!currentStep?.teamId &&
|
||||
currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) &&
|
||||
!state.locked
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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)}`
|
||||
}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
/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'
|
||||
@ -14,23 +15,28 @@ import { de } from 'date-fns/locale'
|
||||
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,12 +44,17 @@ 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 isAdmin = !!session?.user?.isAdmin
|
||||
const [now, setNow] = useState(initialNow)
|
||||
const [editMetaOpen, setEditMetaOpen] = useState(false)
|
||||
|
||||
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
||||
const me = session?.user
|
||||
@ -53,34 +64,64 @@ export function MatchDetails ({ match }: { match: Match }) {
|
||||
const canEditA = isAdmin || isLeaderA
|
||||
const canEditB = isAdmin || isLeaderB
|
||||
|
||||
const isMapVetoOpen = !!match.mapVeto?.isOpen
|
||||
|
||||
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 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 readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
|
||||
|
||||
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
|
||||
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
||||
|
||||
/* ─── Live-Uhr (für Veto-Zeitpunkt) ───────────────────────── */
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const vetoOpensAtTs = useMemo(() => {
|
||||
const base = match.mapVeto?.opensAt
|
||||
? new Date(match.mapVeto.opensAt).getTime()
|
||||
: new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() - 60 * 60 * 1000
|
||||
return base
|
||||
}, [match.mapVeto?.opensAt, match.matchDate, match.demoDate, initialNow])
|
||||
|
||||
const endDate = new Date(vetoOpensAtTs)
|
||||
const mapVetoStarted = (match.mapVeto?.isOpen ?? false) || now >= vetoOpensAtTs
|
||||
|
||||
const showEditA = canEditA && !mapVetoStarted
|
||||
const showEditB = canEditB && !mapVetoStarted
|
||||
|
||||
/* ─── SSE-Listener ─────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
|
||||
// Match gelöscht? → zurück zur Liste
|
||||
if (lastEvent.type === 'match-deleted' && lastEvent.payload?.matchId === match.id) {
|
||||
router.replace('/schedule')
|
||||
return
|
||||
}
|
||||
|
||||
// Alle Match-Events → Seite frisch rendern
|
||||
if (MATCH_EVENTS.has(lastEvent.type) && lastEvent.payload?.matchId === match.id) {
|
||||
router.refresh()
|
||||
}
|
||||
}, [lastEvent, match.id, router])
|
||||
|
||||
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
|
||||
const ColGroup = () => (
|
||||
<colgroup>
|
||||
<col style={{ width: '24%' }} />
|
||||
<col style={{ width: '8%' }} />
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
{Array.from({ length: 13 }).map((_, i) => (
|
||||
<col key={i} style={{ width: '5.666%' }} />
|
||||
))}
|
||||
</colgroup>
|
||||
@ -96,78 +137,81 @@ 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 (
|
||||
<Table>
|
||||
<ColGroup />
|
||||
<Table.Head>
|
||||
<Table.Row>
|
||||
{['Spieler','Rank','Aim','K','A','D','1K','2K','3K','4K','5K',
|
||||
'K/D','ADR','HS%','Damage'].map(h => (
|
||||
<Table.Cell key={h} as="th">{h}</Table.Cell>
|
||||
{[
|
||||
'Spieler',
|
||||
'Rank',
|
||||
'Aim',
|
||||
'K',
|
||||
'A',
|
||||
'D',
|
||||
'1K',
|
||||
'2K',
|
||||
'3K',
|
||||
'4K',
|
||||
'5K',
|
||||
'K/D',
|
||||
'ADR',
|
||||
'HS%',
|
||||
'Damage',
|
||||
].map((h) => (
|
||||
<Table.Cell key={h} as="th">
|
||||
{h}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Head>
|
||||
|
||||
<Table.Body>
|
||||
{sorted.map(p => (
|
||||
<Table.Row
|
||||
key={p.user.steamId}
|
||||
{sorted.map((p) => (
|
||||
<Table.Row key={p.user.steamId}>
|
||||
<Table.Cell
|
||||
className="py-1 flex items-center gap-2"
|
||||
hoverable
|
||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||
>
|
||||
<Table.Cell className="py-1 flex items-center gap-2" hoverable onClick={() => router.push(`/profile/${p.user.steamId}`)} >
|
||||
<img
|
||||
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
|
||||
alt={p.user.name}
|
||||
className="w-8 h-8 rounded-full mr-3"
|
||||
/>
|
||||
<div className='font-semibold text-base'>
|
||||
{p.user.name ?? 'Unbekannt'}
|
||||
</div>
|
||||
<div className="font-semibold text-base">{p.user.name ?? 'Unbekannt'}</div>
|
||||
</Table.Cell>
|
||||
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
{match.matchType === 'premier'
|
||||
? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||
: <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
|
||||
{match.matchType === 'premier' &&
|
||||
typeof p.stats?.rankChange === 'number' && (
|
||||
<span className={`text-sm ${
|
||||
p.stats.rankChange > 0 ? 'text-green-500'
|
||||
: p.stats.rankChange < 0 ? 'text-red-500' : ''}`}>
|
||||
{match.matchType === 'premier' ? (
|
||||
<PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||
) : (
|
||||
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||
)}
|
||||
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
|
||||
<span
|
||||
className={`text-sm ${
|
||||
p.stats.rankChange > 0
|
||||
? 'text-green-500'
|
||||
: p.stats.rankChange < 0
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{p.stats.rankChange > 0 ? '+' : ''}
|
||||
{p.stats.rankChange}
|
||||
</span>
|
||||
@ -199,11 +243,6 @@ export function MatchDetails ({ match }: { match: Match }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Ausgabe-Datum ───────────────────────────────────────── */
|
||||
const readableDate = dateString
|
||||
? format(new Date(dateString), 'PPpp', { locale: de })
|
||||
: 'Unbekannt'
|
||||
|
||||
/* ─── Render ─────────────────────────────────────────────── */
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -212,45 +251,48 @@ export function MatchDetails ({ match }: { match: Match }) {
|
||||
</h1>
|
||||
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setEditMetaOpen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md">
|
||||
Match bearbeiten
|
||||
</Button>
|
||||
<Button onClick={handleDelete} className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md">
|
||||
Match löschen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
||||
|
||||
<div className="text-md">
|
||||
<strong>Teams:</strong>{' '}
|
||||
{match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
|
||||
<strong>Teams:</strong> {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
|
||||
</div>
|
||||
|
||||
<div className="text-md">
|
||||
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
||||
</div>
|
||||
|
||||
<MapVetoBanner match={match} />
|
||||
<MapVetoBanner match={match} initialNow={initialNow} />
|
||||
|
||||
{/* ───────── Team-Blöcke ───────── */}
|
||||
<div className="border-t pt-4 mt-4 space-y-10">
|
||||
{/* Team A */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{match.teamA?.name ?? 'Team A'}
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold">{match.teamA?.name ?? 'Team A'}</h2>
|
||||
|
||||
{canEditA && isFutureMatch && (
|
||||
{showEditA && (
|
||||
<Alert type="soft" color="info" className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||
</span>
|
||||
<Button
|
||||
size='sm'
|
||||
size="sm"
|
||||
onClick={() => setEditSide('A')}
|
||||
className="px-3 py-1.5 text-sm rounded-lg
|
||||
bg-blue-600 hover:bg-blue-700 text-white"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Spieler bearbeiten
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -261,18 +303,38 @@ export function MatchDetails ({ match }: { match: Match }) {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{match.teamB?.logo && (
|
||||
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
|
||||
<Image
|
||||
src={
|
||||
match.teamB.logo
|
||||
? `/assets/img/logos/${match.teamB.logo}`
|
||||
: `/assets/img/logos/cs2.webp`
|
||||
}
|
||||
alt="Teamlogo"
|
||||
fill
|
||||
sizes="64px"
|
||||
quality={75}
|
||||
priority={false}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{match.teamB?.name ?? 'Team B'}
|
||||
</h2>
|
||||
|
||||
{canEditB && isFutureMatch && (
|
||||
{showEditB && (
|
||||
<Alert type="soft" color="info" className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||
</span>
|
||||
<Button
|
||||
size='sm'
|
||||
size="sm"
|
||||
onClick={() => setEditSide('B')}
|
||||
className="px-3 py-1.5 text-sm rounded-lg
|
||||
bg-blue-600 hover:bg-blue-700 text-white"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Spieler bearbeiten
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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 && (
|
||||
<EditMatchMetaModal
|
||||
show
|
||||
onClose={() => setEditMetaOpen(false)}
|
||||
matchId={match.id}
|
||||
defaultTitle={match.title}
|
||||
defaultTeamAId={match.teamA?.id ?? null}
|
||||
defaultTeamBId={match.teamB?.id ?? null}
|
||||
defaultTeamAName={match.teamA?.name ?? null}
|
||||
defaultTeamBName={match.teamB?.name ?? null}
|
||||
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
||||
defaultMap={match.map ?? null}
|
||||
defaultVetoLeadMinutes={60}
|
||||
onSaved={() => { router.refresh() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const [direction, setDirection] = useState<"up" | "down">("down");
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [coords, setCoords] = useState<{ top: number; left: number; width: number }>({ top: 0, left: 0, width: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (open && dropDirection === "auto" && buttonRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
const rect = buttonRef.current!.getBoundingClientRect();
|
||||
const dropdownHeight = 200;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLUListElement>(null); // 👈 NEU
|
||||
|
||||
const selectedOption = useMemo(() => options.find(o => o.value === value), [options, value]);
|
||||
|
||||
const computePosition = () => {
|
||||
if (!buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = 240;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
|
||||
setDirection("up");
|
||||
} else {
|
||||
setDirection("down");
|
||||
}
|
||||
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)
|
||||
});
|
||||
}
|
||||
}, [open, dropDirection]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
if (!open) return;
|
||||
computePosition();
|
||||
|
||||
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 (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
className={`relative py-2 px-4 pe-10 w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 ${className}`}
|
||||
>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
<span className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-500 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path d="M7 10l5 5 5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
const Menu = open
|
||||
? createPortal(
|
||||
<ul
|
||||
className={`absolute z-50 ${
|
||||
(dropDirection === "auto" ? direction : dropDirection) === "up"
|
||||
? "bottom-full mb-2"
|
||||
: "top-full mt-2"
|
||||
} w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto text-sm dark:bg-neutral-900 dark:border-neutral-700`}
|
||||
ref={menuRef} // 👈 wichtig
|
||||
className="z-[9999] fixed bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto text-sm dark:bg-neutral-900 dark:border-neutral-700"
|
||||
style={{
|
||||
left: coords.left,
|
||||
top: coords.top,
|
||||
width: coords.width,
|
||||
transform: direction === "up" ? "translateY(calc(-100% - 8px))" : "translateY(8px)"
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<li
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
@ -86,8 +108,27 @@ export default function Select({ options, placeholder = "Select option...", valu
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ul>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
className={`relative py-2 px-4 pe-10 w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 ${className}`}
|
||||
>
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
<span className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-500 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
||||
<path d="M7 10l5 5 5-5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{Menu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 }) {
|
||||
|
||||
<Table.Body>
|
||||
{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 }) {
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={`/assets/img/mapicons/${m.map}.webp`}
|
||||
alt={mapInfo.name}
|
||||
alt={mapInfo?.label}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
{mapInfo.name}
|
||||
{mapInfo?.label}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
// src/lib/mapNameMap.ts
|
||||
|
||||
export const mapNameMap: Record<string, { name: string }> = {
|
||||
de_train: { name: 'Train' },
|
||||
ar_baggage: { name: 'Baggage' },
|
||||
ar_pool_day: { name: 'Pool Day' },
|
||||
ar_shoots: { name: 'Shoots' },
|
||||
cs_agency: { name: 'Agency' },
|
||||
cs_italy: { name: 'Italy' },
|
||||
cs_office: { name: 'Office' },
|
||||
de_ancient: { name: 'Ancient' },
|
||||
de_anubis: { name: 'Anubis' },
|
||||
de_brewery: { name: 'Brewery' },
|
||||
de_dogtown: { name: 'Dogtown' },
|
||||
de_dust2: { name: 'Dust 2' },
|
||||
de_grail: { name: 'Grail' },
|
||||
de_inferno: { name: 'Inferno' },
|
||||
de_jura: { name: 'Jura' },
|
||||
de_mirage: { name: 'Mirage' },
|
||||
de_nuke: { name: 'Nuke' },
|
||||
de_overpass: { name: 'Overpass' },
|
||||
de_vertigo: { name: 'Vertigo' },
|
||||
lobby_mapveto: { name: 'Pick/Ban' },
|
||||
};
|
||||
25
src/app/lib/mapOptions.ts
Normal file
25
src/app/lib/mapOptions.ts
Normal file
@ -0,0 +1,25 @@
|
||||
// src/lib/mapOptions.ts
|
||||
export type MapOption = { key: string; label: string }
|
||||
|
||||
export const MAP_OPTIONS: MapOption[] = [
|
||||
{ key: 'de_train', label: 'Train' },
|
||||
{ key: 'ar_baggage', label: 'Baggage' },
|
||||
{ key: 'ar_pool_day', label: 'Pool Day' },
|
||||
{ key: 'ar_shoots', label: 'Shoots' },
|
||||
{ key: 'cs_agency', label: 'Agency' },
|
||||
{ key: 'cs_italy', label: 'Italy' },
|
||||
{ key: 'cs_office', label: 'Office' },
|
||||
{ key: 'de_ancient', label: 'Ancient' },
|
||||
{ key: 'de_anubis', label: 'Anubis' },
|
||||
{ key: 'de_brewery', label: 'Brewery' },
|
||||
{ key: 'de_dogtown', label: 'Dogtown' },
|
||||
{ key: 'de_dust2', label: 'Dust 2' },
|
||||
{ key: 'de_grail', label: 'Grail' },
|
||||
{ key: 'de_inferno', label: 'Inferno' },
|
||||
{ key: 'de_jura', label: 'Jura' },
|
||||
{ key: 'de_mirage', label: 'Mirage' },
|
||||
{ key: 'de_nuke', label: 'Nuke' },
|
||||
{ key: 'de_overpass', label: 'Overpass' },
|
||||
{ key: 'de_vertigo', label: 'Vertigo' },
|
||||
{ key: 'lobby_mapveto', label: 'Pick/Ban' },
|
||||
]
|
||||
@ -1,5 +1,4 @@
|
||||
// sseEvents.ts
|
||||
|
||||
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<SSEEventType> = new Set([
|
||||
'team-leader-self',
|
||||
]);
|
||||
|
||||
// ➕ neu: Match-bezogene Events als Gruppe
|
||||
export const MATCH_EVENTS: ReadonlySet<SSEEventType> = new Set([
|
||||
'match-created',
|
||||
'matches-updated',
|
||||
'match-deleted',
|
||||
'match-lineup-updated',
|
||||
'match-updated',
|
||||
]);
|
||||
|
||||
// Event-Typen, die das NotificationCenter betreffen
|
||||
export const NOTIFICATION_EVENTS: ReadonlySet<SSEEventType> = new Set([
|
||||
'notification',
|
||||
|
||||
17
src/app/match-details/[matchId]/MatchContext.tsx
Normal file
17
src/app/match-details/[matchId]/MatchContext.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
// app/match-details/[matchId]/MatchContext.tsx
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Match } from '@/app/types/match'
|
||||
|
||||
const Ctx = createContext<Match | null>(null)
|
||||
|
||||
export function useMatch() {
|
||||
const v = useContext(Ctx)
|
||||
if (!v) throw new Error('MatchContext missing — check layout.tsx')
|
||||
return v
|
||||
}
|
||||
|
||||
export function MatchProvider({ match, children }:{ match: Match; children: React.ReactNode }) {
|
||||
return <Ctx.Provider value={match}>{children}</Ctx.Provider>
|
||||
}
|
||||
11
src/app/match-details/[matchId]/MatchDetailsClient.tsx
Normal file
11
src/app/match-details/[matchId]/MatchDetailsClient.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
// app/match-details/[matchId]/MatchDetailsClient.tsx
|
||||
'use client'
|
||||
|
||||
import { useMatch } from './MatchContext'
|
||||
import { MatchDetails } from '@/app/components/MatchDetails'
|
||||
|
||||
export default function MatchDetailsClient() {
|
||||
const match = useMatch()
|
||||
const initialNow = Date.now()
|
||||
return <MatchDetails match={match} initialNow={initialNow} />
|
||||
}
|
||||
39
src/app/match-details/[matchId]/layout.tsx
Normal file
39
src/app/match-details/[matchId]/layout.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
// app/match-details/[matchId]/layout.tsx
|
||||
import { headers } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { MatchProvider } from './MatchContext'
|
||||
import type { Match } from '@/app/types/match'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
// (optional) falls du sicher Node Runtime willst:
|
||||
// export const runtime = 'nodejs'
|
||||
|
||||
async function loadMatch(matchId: string): Promise<Match | null> {
|
||||
const h = await headers(); // ⬅️ wichtig
|
||||
const proto = (h.get('x-forwarded-proto') ?? 'http').split(',')[0].trim()
|
||||
const host = (h.get('x-forwarded-host') ?? h.get('host') ?? '').split(',')[0].trim()
|
||||
|
||||
// Fallback, falls in seltenen Fällen kein Host vorhanden ist (z. B. bei lokalen Tests)
|
||||
const base = host ? `${proto}://${host}` : (process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000')
|
||||
|
||||
const res = await fetch(`${base}/api/matches/${matchId}`, { cache: 'no-store' })
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export default async function MatchLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: { matchId: string } | Promise<{ matchId: string }>
|
||||
}) {
|
||||
// In neueren Next-Versionen können params ein Promise sein:
|
||||
const { matchId } = await Promise.resolve(params as any)
|
||||
|
||||
const match = await loadMatch(matchId)
|
||||
if (!match) return notFound()
|
||||
|
||||
return <MatchProvider match={match}>{children}</MatchProvider>
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
// /app/match-details/[matchId]/map-vote/page.tsx
|
||||
import { notFound } from 'next/navigation'
|
||||
import Card from '@/app/components/Card'
|
||||
import MapVetoPanel from '@/app/components/MapVetoPanel'
|
||||
|
||||
async function loadMatch(id: string) {
|
||||
const r = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/api/matches/${id}`, { cache: 'no-store' })
|
||||
if (!r.ok) return null
|
||||
return r.json()
|
||||
}
|
||||
|
||||
export default async function MapVetoPage({ params }: { params: { matchId: string } }) {
|
||||
const match = await loadMatch(params.matchId)
|
||||
if (!match) return notFound()
|
||||
return (
|
||||
<Card maxWidth="auto">
|
||||
<MapVetoPanel match={match} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -1,28 +1,6 @@
|
||||
// /app/match-details/[matchId]/page.tsx
|
||||
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 <div className="p-8 text-red-600">Fehler beim Laden des Matches</div>
|
||||
}
|
||||
|
||||
const match: Match = await res.json()
|
||||
|
||||
return (
|
||||
<Card maxWidth="auto">
|
||||
<MatchDetails match={match} />
|
||||
</Card>
|
||||
)
|
||||
export default function MatchDetailsPage() {
|
||||
return <MatchDetailsClient />
|
||||
}
|
||||
|
||||
10
src/app/match-details/[matchId]/vote/VoteClient.tsx
Normal file
10
src/app/match-details/[matchId]/vote/VoteClient.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
// app/match-details/[matchId]/vote/VoteClient.tsx
|
||||
'use client'
|
||||
|
||||
import MapVetoPanel from '@/app/components/MapVetoPanel'
|
||||
import { useMatch } from '../MatchContext' // aus dem Layout-Context
|
||||
|
||||
export default function VoteClient() {
|
||||
const match = useMatch()
|
||||
return <MapVetoPanel match={match} />
|
||||
}
|
||||
11
src/app/match-details/[matchId]/vote/page.tsx
Normal file
11
src/app/match-details/[matchId]/vote/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
// app/match-details/[matchId]/vote/page.tsx
|
||||
import Card from '@/app/components/Card'
|
||||
import VoteClient from './VoteClient' // Client-Komponente
|
||||
|
||||
export default function VotePage() {
|
||||
return (
|
||||
<Card maxWidth="auto">
|
||||
<VoteClient />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -10,9 +10,50 @@ import { updatePremierRanksForUser } from './updatePremierRanks';
|
||||
import fs from 'fs';
|
||||
import 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_<map>_<matchId>_<premier|competitive>.dem[.part]
|
||||
const re = new RegExp(`^match\\d+_.+_${matchId}_(premier|competitive)\\.dem(\\.part)?$`, 'i');
|
||||
|
||||
if (!fs.existsSync(demosRoot)) return null;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(demosRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const dirent of entries) {
|
||||
if (!dirent.isDirectory()) continue;
|
||||
if (dirent.name === 'temp') continue; // temp auslassen
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dirent.name)) continue; // nur YYYY-MM-DD
|
||||
|
||||
const dayDir = path.join(demosRoot, dirent.name);
|
||||
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = fs.readdirSync(dayDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const fname of files) {
|
||||
if (!fname.endsWith('.dem') && !fname.endsWith('.dem.part')) continue;
|
||||
if (re.test(fname)) {
|
||||
return path.join(dayDir, fname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function startCS2MatchCron() {
|
||||
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,11 +161,17 @@ 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 (fs.existsSync(expectedFilePath)) {
|
||||
log(`[${user.steamId}] 📁 Match ${matchInfo.matchId} wurde bereits als Datei gespeichert – übersprungen`);
|
||||
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.
|
||||
|
||||
await prisma.user.update({
|
||||
where: { steamId: user.steamId },
|
||||
@ -136,6 +183,7 @@ async function runMatchCheck() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// kein File vorhanden -> Downloader/Parser anstoßen
|
||||
const result = await runDownloaderForUser({
|
||||
...user,
|
||||
lastKnownShareCode: shareCode,
|
||||
|
||||
@ -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(`[${steamId}] 📥 Lade Demo herunter...`)
|
||||
log(`[${user.steamId}] 📥 Lade Demo herunter...`);
|
||||
|
||||
// ───────────────────────── HTTP-Aufruf an Downloader ─────────────────────────
|
||||
let data: DownloadResponse
|
||||
try {
|
||||
// 🎯 Nur HTTP-Modus
|
||||
const res = await fetch('http://localhost:4000/download', {
|
||||
method : 'POST',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({ steamId, shareCode }),
|
||||
})
|
||||
body: JSON.stringify({ steamId, shareCode }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
log(`[${steamId}] ❌ Downloader HTTP ${res.status}: ${text || res.statusText}`, 'error')
|
||||
return { newMatches: [], latestShareCode: shareCode }
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success) {
|
||||
log(`[${steamId}] ❌ Downloader-Fehler: ${data.error}`, 'error');
|
||||
}
|
||||
|
||||
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 ?? '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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user