updated mapvote

This commit is contained in:
Linrador 2025-08-15 13:34:23 +02:00
parent 6caf57d282
commit a832abff2e
40 changed files with 3338 additions and 2423 deletions

View File

@ -44,7 +44,7 @@ model User {
createdSchedules Schedule[] @relation("CreatedSchedules") createdSchedules Schedule[] @relation("CreatedSchedules")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules") confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVetoChoices MapVetoStep[] @relation("VetoStepChooser") mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
} }
model Team { model Team {
@ -68,7 +68,7 @@ model Team {
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA") schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB") schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVetoSteps MapVetoStep[] @relation("VetoStepTeam") mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
} }
model TeamInvite { model TeamInvite {
@ -138,7 +138,7 @@ model Match {
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate) matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVeto MapVeto? // 1:1 Map-Vote-Status mapVote MapVote?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -297,51 +297,50 @@ model ServerRequest {
// 🗺️ Map-Vote // 🗺️ Map-Vote
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
enum MapVetoAction { enum MapVoteAction {
BAN BAN
PICK PICK
DECIDER DECIDER
} }
model MapVeto { model MapVote {
id String @id @default(uuid()) id String @id @default(uuid())
matchId String @unique matchId String @unique
match Match @relation(fields: [matchId], references: [id]) match Match @relation(fields: [matchId], references: [id])
// Basiszustand
bestOf Int @default(3) bestOf Int @default(3)
mapPool String[] // z.B. ["de_inferno","de_mirage",...] mapPool String[]
currentIdx Int @default(0) currentIdx Int @default(0)
locked Boolean @default(false) locked Boolean @default(false)
opensAt DateTime?
// Optional: serverseitig speichern, statt im UI zu berechnen adminEditingBy String?
opensAt DateTime? adminEditingSince DateTime?
steps MapVetoStep[] steps MapVoteStep[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model MapVetoStep { model MapVoteStep {
id String @id @default(uuid()) id String @id @default(uuid())
vetoId String voteId String
order Int order Int
action MapVetoAction action MapVoteAction
// Team, das am Zug ist (kann bei DECIDER null sein)
teamId String? teamId String?
team Team? @relation("VetoStepTeam", fields: [teamId], references: [id]) team Team? @relation("VoteStepTeam", fields: [teamId], references: [id])
// Ergebnis & wer gewählt hat
map String? map String?
chosenAt DateTime? chosenAt DateTime?
chosenBy String? chosenBy String?
chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId]) chooser User? @relation("VoteStepChooser", fields: [chosenBy], references: [steamId])
veto MapVeto @relation(fields: [vetoId], references: [id]) vote MapVote @relation(fields: [voteId], references: [id])
@@unique([vetoId, order]) @@unique([voteId, order])
@@index([teamId]) @@index([teamId])
@@index([chosenBy]) @@index([chosenBy])
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -56,7 +56,7 @@ export async function buildCommunityFuturePayload(m: any) {
.sort((a: any, b: any) => (a.user.name || '').localeCompare(b.user.name || '')) .sort((a: any, b: any) => (a.user.name || '').localeCompare(b.user.name || ''))
const startTs = computeStartTs(m) const startTs = computeStartTs(m)
const editableUntil = startTs - 60 * 60 * 1000 // 1h vor Start/Veto const editableUntil = startTs - 60 * 60 * 1000 // 1h vor Start/Vote
return { return {
id : m.id, id : m.id,

View File

@ -26,8 +26,8 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.mapVetoStep.deleteMany({ where: { veto: { matchId } } }) await tx.mapVoteStep.deleteMany({ where: { vote: { matchId } } })
await tx.mapVeto.deleteMany({ where: { matchId } }) await tx.mapVote.deleteMany({ where: { matchId } })
await tx.playerStats.deleteMany({ where: { matchId } }) await tx.playerStats.deleteMany({ where: { matchId } })
await tx.matchPlayer.deleteMany({ where: { matchId } }) await tx.matchPlayer.deleteMany({ where: { matchId } })
await tx.rankHistory.deleteMany({ where: { matchId } }) await tx.rankHistory.deleteMany({ where: { matchId } })

View File

@ -0,0 +1,191 @@
// /app/api/matches/[matchId]/mapvote/admin-edit/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 { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { randomInt } from 'crypto'
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
/** -------- helpers copied (light) from main vote route -------- */
const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
BAN: 'ban', PICK: 'pick', DECIDER: 'decider',
}
function mapActionToApi(a: MapVoteAction): 'ban'|'pick'|'decider' {
return ACTION_MAP[a]
}
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
if (bestOf === 3) {
return [
{ order: 0, action: 'BAN', teamId: teamAId },
{ order: 1, action: 'BAN', teamId: teamBId },
{ order: 2, action: 'PICK', teamId: teamAId },
{ order: 3, action: 'PICK', teamId: teamBId },
{ order: 4, action: 'BAN', teamId: teamAId },
{ order: 5, action: 'BAN', teamId: teamBId },
{ order: 6, action: 'DECIDER', teamId: null },
] as const
}
return [
{ order: 0, action: 'BAN', teamId: teamAId },
{ order: 1, action: 'BAN', teamId: teamBId },
{ order: 2, action: 'PICK', teamId: teamAId },
{ order: 3, action: 'PICK', teamId: teamBId },
{ order: 4, action: 'PICK', teamId: teamAId },
{ order: 5, action: 'PICK', teamId: teamBId },
{ order: 6, action: 'PICK', teamId: teamAId },
] as const
}
async function ensureVote(matchId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA: true,
teamB: true,
mapVote: { include: { steps: true } },
},
})
if (!match) return { match: null, vote: null }
if (match.mapVote) return { match, vote: match.mapVote }
const bestOf = match.bestOf ?? 3
const mapPool = MAP_OPTIONS.map(m => m.key)
const firstIsA = (typeof randomInt === 'function') ? randomInt(0, 2) === 0 : Math.random() < 0.5
const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id
const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id
const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId)
const created = await prisma.mapVote.create({
data: {
matchId: match.id,
bestOf,
mapPool,
currentIdx: 0,
locked: false,
steps: {
create: stepsDef.map(s => ({
order: s.order,
action: s.action as MapVoteAction,
teamId: s.teamId,
})),
},
},
include: { steps: true },
})
return { match, vote: created }
}
function shapeAdminEdit(vote: any) {
return vote.adminEditingBy
? {
enabled: true,
by: vote.adminEditingBy as string,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
}
: { enabled: false as const, by: null, since: null }
}
function shapeStateSlim(vote: any) {
return {
bestOf: vote.bestOf as number,
mapPool: vote.mapPool as string[],
currentIndex: vote.currentIdx as number,
locked: vote.locked as boolean,
adminEdit: shapeAdminEdit(vote),
steps: [...vote.steps].sort((a: any, b: any) => a.order - b.order).map((s: any) => ({
order : s.order,
action : mapActionToApi(s.action),
teamId : s.teamId,
map : s.map ?? null,
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
chosenBy: s.chosenBy ?? null,
})),
}
}
/** ------------------ POST (toggle admin edit) ------------------ */
// Body: { enabled: boolean, force?: boolean }
// - enabled=true: setzt adminEditingBy = me.steamId (falls schon anderer Admin aktiv -> 409, außer force)
// - enabled=false: cleart adminEditingBy, wenn ich der aktive Editor bin (oder force)
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 })
if (!me.isAdmin) return NextResponse.json({ message: 'Nur Admins' }, { status: 403 })
const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
let body: { enabled?: boolean; force?: boolean } = {}
try { body = await req.json() } catch {}
const enabled = !!body.enabled
const force = !!body.force
try {
const { match, vote } = await ensureVote(matchId)
if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const someoneElseActive = vote.adminEditingBy && vote.adminEditingBy !== me.steamId
// Konfliktbehandlung
if (enabled && someoneElseActive && !force) {
return NextResponse.json(
{
message: 'Bereits im Admin-Edit durch anderen Benutzer',
adminEdit: shapeAdminEdit(vote),
},
{ status: 409 },
)
}
// Toggle speichern
const updated = await prisma.mapVote.update({
where: { id: vote.id },
data: enabled
? { adminEditingBy: me.steamId, adminEditingSince: new Date() }
: (force || vote.adminEditingBy === me.steamId
? { adminEditingBy: null, adminEditingSince: null }
: {} // nichts ändern, wenn jemand anderer aktiv ist und !force
),
include: { steps: true },
})
// 🔔 gezieltes SSE-Event
await sendServerSSEMessage({
type: 'map-vote-admin-edit',
matchId,
payload: {
enabled: !!updated.adminEditingBy,
by: updated.adminEditingBy ?? null,
since: updated.adminEditingSince ?? null,
},
})
return NextResponse.json({ adminEdit: shapeAdminEdit(updated), state: shapeStateSlim(updated) })
} catch (e) {
console.error('[map-vote][POST admin-edit] error', e)
return NextResponse.json({ message: 'Toggle fehlgeschlagen' }, { status: 500 })
}
}
/** ------------------ GET (optional status) ------------------ */
// Praktisch, falls du den Status separat pollen willst.
export async function GET(_req: NextRequest, { params }: { params: { matchId: string } }) {
const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
try {
const { match, vote } = await ensureVote(matchId)
if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
return NextResponse.json({ adminEdit: shapeAdminEdit(vote), state: shapeStateSlim(vote) })
} catch (e) {
console.error('[map-vote][GET admin-edit] error', e)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
}
}

View File

@ -3,41 +3,43 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { MapVetoAction } from '@/generated/prisma' import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
/** gleicher Pool wie in deiner mapvote-Route */ // ---- Pool aus MAP_OPTIONS ableiten (nur "de_*", ohne Sonderkarten) ----
const ACTIVE_DUTY: string[] = [ const MAP_POOL: string[] = MAP_OPTIONS
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis', .filter(m => m.key.startsWith('de_') && m.key !== 'lobby_mapvote')
] .map(m => m.key)
/** identische Logik wie in deiner mapvote-Route */ // identisch zu mapvote-Route
function vetoOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) { function voteOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
const base = match.matchDate ?? match.demoDate ?? new Date() const base = match.matchDate ?? match.demoDate ?? new Date()
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher return new Date(base.getTime() - 60 * 60 * 1000)
} }
function buildSteps(bestOf: number, teamAId: string, teamBId: string) { // buildSteps so umbauen, dass die Reihenfolge (Startteam) variabel ist
function buildSteps(bestOf: number, firstId: string, secondId: string) {
if (bestOf === 3) { if (bestOf === 3) {
return [ return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId }, { order: 0, action: MapVoteAction.BAN, teamId: firstId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId }, { order: 1, action: MapVoteAction.BAN, teamId: secondId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId }, { order: 2, action: MapVoteAction.PICK, teamId: firstId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId }, { order: 3, action: MapVoteAction.PICK, teamId: secondId },
{ order: 4, action: MapVetoAction.BAN, teamId: teamAId }, { order: 4, action: MapVoteAction.BAN, teamId: firstId },
{ order: 5, action: MapVetoAction.BAN, teamId: teamBId }, { order: 5, action: MapVoteAction.BAN, teamId: secondId },
{ order: 6, action: MapVetoAction.DECIDER, teamId: null }, { order: 6, action: MapVoteAction.DECIDER, teamId: null },
] as const ] as const
} }
// BO5 // BO5
return [ return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId }, { order: 0, action: MapVoteAction.BAN, teamId: firstId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId }, { order: 1, action: MapVoteAction.BAN, teamId: secondId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId }, { order: 2, action: MapVoteAction.PICK, teamId: firstId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId }, { order: 3, action: MapVoteAction.PICK, teamId: secondId },
{ order: 4, action: MapVetoAction.PICK, teamId: teamAId }, { order: 4, action: MapVoteAction.PICK, teamId: firstId },
{ order: 5, action: MapVetoAction.PICK, teamId: teamBId }, { order: 5, action: MapVoteAction.PICK, teamId: secondId },
{ order: 6, action: MapVetoAction.PICK, teamId: teamAId }, { order: 6, action: MapVoteAction.PICK, teamId: firstId },
] as const ] as const
} }
@ -50,7 +52,6 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
const matchId = params.matchId const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 }) if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
// Match laden (inkl. Teams & BestOf für Steps)
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id: matchId }, where: { id: matchId },
select: { select: {
@ -60,29 +61,32 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
demoDate: true, demoDate: true,
teamA: { select: { id: true } }, teamA: { select: { id: true } },
teamB: { select: { id: true } }, teamB: { select: { id: true } },
mapVeto: { select: { id: true } }, mapVote: { select: { id: true } },
}, },
}) })
if (!match || !match.teamA?.id || !match.teamB?.id) { if (!match?.teamA?.id || !match?.teamB?.id) {
return NextResponse.json({ message: 'Match/Teams nicht gefunden' }, { status: 404 }) return NextResponse.json({ message: 'Match/Teams nicht gefunden' }, { status: 404 })
} }
const bestOf = match.bestOf ?? 3 const bestOf = match.bestOf ?? 3
const stepsDef = buildSteps(bestOf, match.teamA.id, match.teamB.id) // ---- Zufälliges Startteam bestimmen ----
const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null }) const firstId = Math.random() < 0.5 ? match.teamA.id : match.teamB.id
const secondId = firstId === match.teamA.id ? match.teamB.id : match.teamA.id
const stepsDef = buildSteps(bestOf, firstId, secondId)
const opensAt = voteOpensAt({ 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) => { await prisma.$transaction(async (tx) => {
if (match.mapVeto?.id) { if (match.mapVote?.id) {
await tx.mapVetoStep.deleteMany({ where: { vetoId: match.mapVeto.id } }) await tx.mapVoteStep.deleteMany({ where: { voteId: match.mapVote.id } })
await tx.mapVeto.delete({ where: { matchId } }) await tx.mapVote.delete({ where: { matchId } })
} }
await tx.mapVeto.create({ await tx.mapVote.create({
data: { data: {
matchId, matchId,
bestOf, bestOf,
mapPool: ACTIVE_DUTY, mapPool: MAP_POOL, // <- aus MAP_OPTIONS
currentIdx: 0, currentIdx: 0,
locked: false, locked: false,
opensAt, opensAt,
@ -97,8 +101,6 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
}) })
}) })
// 🔔 UI-Refresh für alle Clients
await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
return NextResponse.json({ ok: true }) return NextResponse.json({ ok: true })
} }

View File

@ -3,27 +3,37 @@ import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { MapVetoAction } from '@/generated/prisma' import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { randomInt } from 'crypto'
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
import { createHash } from 'crypto'
/* -------------------- Konstanten -------------------- */ /* -------------------- Konstanten -------------------- */
const ACTIVE_DUTY: string[] = [ const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
]
const ACTION_MAP: Record<MapVetoAction, 'ban'|'pick'|'decider'> = {
BAN: 'ban', PICK: 'pick', DECIDER: 'decider', BAN: 'ban', PICK: 'pick', DECIDER: 'decider',
} }
/* -------------------- Helper -------------------- */ /* -------------------- Helper -------------------- */
function vetoOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) { // Admin-Edit-Flag setzen/zurücksetzen
async function setAdminEdit(voteId: string, by: string | null) {
return prisma.mapVote.update({
where: { id: voteId },
data: by
? { adminEditingBy: by, adminEditingSince: new Date() }
: { adminEditingBy: null, adminEditingSince: null },
include: { steps: true },
})
}
function voteOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
const base = match.matchDate ?? match.demoDate ?? new Date() const base = match.matchDate ?? match.demoDate ?? new Date()
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
} }
function mapActionToApi(a: MapVetoAction): 'ban'|'pick'|'decider' { function mapActionToApi(a: MapVoteAction): 'ban'|'pick'|'decider' {
return ACTION_MAP[a] return ACTION_MAP[a]
} }
@ -52,8 +62,8 @@ function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
] as const ] as const
} }
function shapeState(veto: any) { function shapeState(vote: any) {
const steps = [...veto.steps] const steps = [...vote.steps]
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((s: any) => ({ .map((s: any) => ({
order : s.order, order : s.order,
@ -65,12 +75,20 @@ function shapeState(veto: any) {
})) }))
return { return {
bestOf : veto.bestOf, bestOf : vote.bestOf,
mapPool : veto.mapPool as string[], mapPool : vote.mapPool as string[],
currentIndex: veto.currentIdx, currentIndex: vote.currentIdx,
locked : veto.locked as boolean, locked : vote.locked as boolean,
opensAt : veto.opensAt ? new Date(veto.opensAt).toISOString() : null, opensAt : vote.opensAt ? new Date(vote.opensAt).toISOString() : null,
steps, steps,
// Admin-Edit Shape
adminEdit: vote.adminEditingBy
? {
enabled: true,
by: vote.adminEditingBy as string,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null,
}
: { enabled: false, by: null, since: null },
} }
} }
@ -102,7 +120,6 @@ function shapePlayer(p: any) {
// Base-URL aus Request ableiten (lokal/proxy-fähig) // Base-URL aus Request ableiten (lokal/proxy-fähig)
function getBaseUrl(req: NextRequest | NextResponse) { function getBaseUrl(req: NextRequest | NextResponse) {
// NextRequest hat headers; bei internen Aufrufen ggf. NextResponse, hier aber nur Request relevant
const proto = (req.headers.get('x-forwarded-proto') || 'http').split(',')[0].trim() const proto = (req.headers.get('x-forwarded-proto') || 'http').split(',')[0].trim()
const host = (req.headers.get('x-forwarded-host') || req.headers.get('host') || '').split(',')[0].trim() const host = (req.headers.get('x-forwarded-host') || req.headers.get('host') || '').split(',')[0].trim()
return `${proto}://${host}` return `${proto}://${host}`
@ -115,10 +132,8 @@ async function fetchTeamApi(teamId: string | null | undefined, req: NextRequest)
try { try {
const r = await fetch(url, { const r = await fetch(url, {
// interne Server-Fetches dürfen nicht gecacht werden
cache: 'no-store', cache: 'no-store',
headers: { headers: {
// Forward auth/proxy headers, falls nötig (nicht zwingend)
'x-forwarded-proto': req.headers.get('x-forwarded-proto') || '', 'x-forwarded-proto': req.headers.get('x-forwarded-proto') || '',
'x-forwarded-host' : req.headers.get('x-forwarded-host') || '', 'x-forwarded-host' : req.headers.get('x-forwarded-host') || '',
} }
@ -129,7 +144,7 @@ async function fetchTeamApi(teamId: string | null | undefined, req: NextRequest)
id: string id: string
name?: string | null name?: string | null
logo?: string | null logo?: string | null
leader?: string | null // LeaderId leader?: string | null
activePlayers: any[] activePlayers: any[]
inactivePlayers: any[] inactivePlayers: any[]
invitedPlayers: any[] invitedPlayers: any[]
@ -139,7 +154,35 @@ async function fetchTeamApi(teamId: string | null | undefined, req: NextRequest)
} }
} }
// Leader bevorzugt aus Match-Relation; Fallback über Team-API (LeaderId -> Player aus Listen) // Teams-Payload (mit Spielern) zusammenbauen
async function buildTeamsPayload(match: any, req: NextRequest) {
const [teamAApi, teamBApi] = await Promise.all([
fetchTeamApi(match.teamA?.id, req),
fetchTeamApi(match.teamB?.id, req),
])
const teamAPlayers = (teamAApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean)
const teamBPlayers = (teamBApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean)
return {
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader : resolveLeaderPlayer(match.teamA, teamAApi),
players: teamAPlayers,
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader : resolveLeaderPlayer(match.teamB, teamBApi),
players: teamBPlayers,
},
}
}
// Leader bevorzugt aus Match-Relation; Fallback über Team-API
function resolveLeaderPlayer(matchTeam: any | null | undefined, teamApi: any | null) { function resolveLeaderPlayer(matchTeam: any | null | undefined, teamApi: any | null) {
const leaderFromMatch = shapeLeader(matchTeam?.leader ?? null) const leaderFromMatch = shapeLeader(matchTeam?.leader ?? null)
if (leaderFromMatch) return leaderFromMatch if (leaderFromMatch) return leaderFromMatch
@ -156,13 +199,12 @@ function resolveLeaderPlayer(matchTeam: any | null | undefined, teamApi: any | n
return shapePlayer(found) ?? { steamId: leaderId, name: '', avatar: '' } return shapePlayer(found) ?? { steamId: leaderId, name: '', avatar: '' }
} }
async function ensureVeto(matchId: string) { async function ensureVote(matchId: string) {
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id: matchId }, where: { id: matchId },
include: { include: {
teamA : { teamA : {
include: { include: {
// Leader-Relation als Objekt laden
leader: { leader: {
select: { select: {
steamId: true, steamId: true,
@ -189,21 +231,25 @@ async function ensureVeto(matchId: string) {
} }
} }
}, },
mapVeto: { include: { steps: true } }, mapVote: { include: { steps: true } },
}, },
}) })
if (!match) return { match: null, veto: null } if (!match) return { match: null, vote: null }
// Bereits vorhanden? // Bereits vorhanden?
if (match.mapVeto) return { match, veto: match.mapVeto } if (match.mapVote) return { match, vote: match.mapVote }
// Neu anlegen // Neu anlegen
const bestOf = match.bestOf ?? 3 const bestOf = match.bestOf ?? 3
const mapPool = ACTIVE_DUTY const mapPool = MAP_OPTIONS.map(m => m.key)
const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null }) const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const stepsDef = buildSteps(bestOf, match.teamA!.id, match.teamB!.id)
const created = await prisma.mapVeto.create({ const firstIsA = (typeof randomInt === 'function') ? randomInt(0, 2) === 0 : Math.random() < 0.5
const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id
const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id
const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId)
const created = await prisma.mapVote.create({
data: { data: {
matchId : match.id, matchId : match.id,
bestOf, bestOf,
@ -214,7 +260,7 @@ async function ensureVeto(matchId: string) {
steps : { steps : {
create: stepsDef.map(s => ({ create: stepsDef.map(s => ({
order : s.order, order : s.order,
action: s.action as MapVetoAction, action: s.action as MapVoteAction,
teamId: s.teamId, teamId: s.teamId,
})), })),
}, },
@ -222,7 +268,7 @@ async function ensureVeto(matchId: string) {
include: { steps: true }, include: { steps: true },
}) })
return { match, veto: created } return { match, vote: created }
} }
function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | null }>) { function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | null }>) {
@ -230,32 +276,27 @@ function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | nu
return mapPool.filter(m => !used.has(m)) return mapPool.filter(m => !used.has(m))
} }
// Teams-Payload (mit Spielern) zusammenbauen /* ---------- Visuals: deterministisches zufälliges Bild pro Map & Match ---------- */
async function buildTeamsPayload(match: any, req: NextRequest) {
const [teamAApi, teamBApi] = await Promise.all([
fetchTeamApi(match.teamA?.id, req),
fetchTeamApi(match.teamB?.id, req),
])
const teamAPlayers = (teamAApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean) function buildMapVisuals(matchId: string, mapPool: string[]) {
const teamBPlayers = (teamBApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean) const visuals: Record<string, { label: string; bg: string; images?: string[] }> = {}
for (const key of mapPool) {
const opt = MAP_OPTIONS.find(o => o.key === key)
const label = opt?.label ?? key
const imgs = opt?.images ?? []
let bg = `/assets/img/maps/${key}/1.jpg`
return { if (imgs.length > 0) {
teamA: { // deterministischer Index auf Basis von matchId+key
id : match.teamA?.id ?? null, const h = createHash('sha256').update(`${matchId}:${key}`).digest('hex')
name : match.teamA?.name ?? null, const n = parseInt(h.slice(0, 8), 16) // 32-bit
logo : match.teamA?.logo ?? null, const idx = n % imgs.length
leader: resolveLeaderPlayer(match.teamA, teamAApi), bg = imgs[idx]
players: teamAPlayers, }
},
teamB: { visuals[key] = { label, bg } // images optional mitgeben: { label, bg, images: imgs }
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader: resolveLeaderPlayer(match.teamB, teamBApi),
players: teamBPlayers,
},
} }
return visuals
} }
/* -------------------- GET -------------------- */ /* -------------------- GET -------------------- */
@ -265,13 +306,14 @@ export async function GET(req: NextRequest, { params }: { params: { matchId: str
const matchId = params.matchId const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 }) if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
const { match, veto } = await ensureVeto(matchId) const { match, vote } = await ensureVote(matchId)
if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 }) if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const teams = await buildTeamsPayload(match, req) const teams = await buildTeamsPayload(match, req)
const mapVisuals = buildMapVisuals(match.id, vote.mapPool)
return NextResponse.json( return NextResponse.json(
{ ...shapeState(veto), teams }, { ...shapeState(vote), mapVisuals, teams },
{ headers: { 'Cache-Control': 'no-store' } }, { headers: { 'Cache-Control': 'no-store' } },
) )
} catch (e) { } catch (e) {
@ -290,43 +332,61 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
const matchId = params.matchId const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 }) if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
let body: { map?: string } = {} type ToggleBody = { map?: string; adminEdit?: boolean }
let body: ToggleBody = {}
try { body = await req.json() } catch {} try { body = await req.json() } catch {}
try { try {
const { match, veto } = await ensureVeto(matchId) const { match, vote } = await ensureVote(matchId)
if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 }) if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
// Öffnungsfenster (1h vor Match-/Demo-Beginn) /* -------- Admin-Edit umschalten (früher Exit) -------- */
const opensAt = veto.opensAt ?? vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null }) if (typeof body.adminEdit === 'boolean') {
const isOpen = new Date() >= new Date(opensAt) if (!me.isAdmin) {
if (!isOpen && !me.isAdmin) return NextResponse.json({ message: 'Veto ist noch nicht offen' }, { status: 403 }) return NextResponse.json({ message: 'Nur Admins dürfen den Edit-Mode setzen' }, { status: 403 })
}
// Schon abgeschlossen? const updated = await setAdminEdit(vote.id, body.adminEdit ? me.steamId : null)
if (veto.locked) return NextResponse.json({ message: 'Veto bereits abgeschlossen' }, { status: 409 })
// Aktuellen Schritt bestimmen
const stepsSorted = [...veto.steps].sort((a: any, b: any) => a.order - b.order)
const current = stepsSorted.find((s: any) => s.order === veto.currentIdx)
if (!current) {
// Kein Schritt mehr -> Veto abschließen
await prisma.mapVeto.update({ where: { id: veto.id }, data: { locked: true } })
const updated = await prisma.mapVeto.findUnique({
where: { id: veto.id },
include: { steps: true },
})
// 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
const teams = await buildTeamsPayload(match, req) const teams = await buildTeamsPayload(match, req)
const mapVisuals = buildMapVisuals(match.id, updated.mapPool)
return NextResponse.json({ ...shapeState(updated), teams }) return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
} }
const available = computeAvailableMaps(veto.mapPool, stepsSorted) /* -------- Wenn anderer Admin editiert: Voting sperren -------- */
if (vote.adminEditingBy && vote.adminEditingBy !== me.steamId) {
return NextResponse.json({ message: 'Admin-Edit aktiv Voting vorübergehend deaktiviert' }, { status: 423 })
}
/* -------- Zeitfenster prüfen (Admins dürfen trotzdem) -------- */
const opensAt = vote.opensAt ?? voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const isOpen = new Date() >= new Date(opensAt)
if (!isOpen && !me.isAdmin) return NextResponse.json({ message: 'Voting ist noch nicht offen' }, { status: 403 })
// Schon abgeschlossen?
if (vote.locked) return NextResponse.json({ message: 'Voting bereits abgeschlossen' }, { status: 409 })
// Aktuellen Schritt bestimmen
const stepsSorted = [...vote.steps].sort((a: any, b: any) => a.order - b.order)
const current = stepsSorted.find((s: any) => s.order === vote.currentIdx)
if (!current) {
// Kein Schritt mehr -> Vote abschließen
await prisma.mapVote.update({ where: { id: vote.id }, data: { locked: true } })
const updated = await prisma.mapVote.findUnique({ where: { id: vote.id }, include: { steps: true } })
await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
const teams = await buildTeamsPayload(match, req)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
}
const available = computeAvailableMaps(vote.mapPool, stepsSorted)
// DECIDER automatisch setzen, wenn nur noch 1 Map übrig // DECIDER automatisch setzen, wenn nur noch 1 Map übrig
if (current.action === 'DECIDER') { if (current.action === 'DECIDER') {
@ -335,30 +395,30 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
} }
const lastMap = available[0] const lastMap = available[0]
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.mapVetoStep.update({ await tx.mapVoteStep.update({
where: { id: current.id }, where: { id: current.id },
data : { map: lastMap, chosenAt: new Date(), chosenBy: me.steamId }, data : { map: lastMap, chosenAt: new Date(), chosenBy: me.steamId },
}) })
await tx.mapVeto.update({ await tx.mapVote.update({
where: { id: veto.id }, where: { id: vote.id },
data : { currentIdx: veto.currentIdx + 1, locked: true }, data : { currentIdx: vote.currentIdx + 1, locked: true },
}) })
}) })
const updated = await prisma.mapVeto.findUnique({ const updated = await prisma.mapVote.findUnique({
where: { id: veto.id }, where: { id: vote.id },
include: { steps: true }, include: { steps: true },
}) })
// 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
const teams = await buildTeamsPayload(match, req) const teams = await buildTeamsPayload(match, req)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
return NextResponse.json({ ...shapeState(updated), teams }) return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
} }
// Rechte prüfen (Admin oder Leader des Teams am Zug) weiterhin via leaderId // Rechte prüfen (Admin oder Leader des Teams am Zug)
const isLeaderA = !!(match as any).teamA?.leaderId && (match as any).teamA.leaderId === me.steamId const isLeaderA = !!(match as any).teamA?.leaderId && (match as any).teamA.leaderId === me.steamId
const isLeaderB = !!(match as any).teamB?.leaderId && (match as any).teamB.leaderId === me.steamId const isLeaderB = !!(match as any).teamB?.leaderId && (match as any).teamB.leaderId === me.steamId
const allowed = me.isAdmin || (current.teamId && ( const allowed = me.isAdmin || (current.teamId && (
@ -370,20 +430,20 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
// Payload validieren // Payload validieren
const map = body.map?.trim() const map = body.map?.trim()
if (!map) return NextResponse.json({ message: 'map fehlt' }, { status: 400 }) if (!map) return NextResponse.json({ message: 'map fehlt' }, { status: 400 })
if (!veto.mapPool.includes(map)) return NextResponse.json({ message: 'Map nicht im Pool' }, { status: 400 }) if (!vote.mapPool.includes(map)) return NextResponse.json({ message: 'Map nicht im Pool' }, { status: 400 })
if (!available.includes(map)) return NextResponse.json({ message: 'Map bereits vergeben' }, { status: 409 }) if (!available.includes(map)) return NextResponse.json({ message: 'Map bereits vergeben' }, { status: 409 })
// Schritt setzen & ggf. weiterdrehen (+ Decider evtl. auto) // Schritt setzen & ggf. weiterdrehen (+ Decider evtl. auto)
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// aktuellen Schritt setzen // aktuellen Schritt setzen
await tx.mapVetoStep.update({ await tx.mapVoteStep.update({
where: { id: current.id }, where: { id: current.id },
data : { map, chosenAt: new Date(), chosenBy: me.steamId }, data : { map, chosenAt: new Date(), chosenBy: me.steamId },
}) })
// neuen Zustand ermitteln // neuen Zustand ermitteln
const after = await tx.mapVeto.findUnique({ const after = await tx.mapVote.findUnique({
where : { id: veto.id }, where : { id: vote.id },
include: { steps: true }, include: { steps: true },
}) })
if (!after) return if (!after) return
@ -397,7 +457,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
if (next?.action === 'DECIDER') { if (next?.action === 'DECIDER') {
const avail = computeAvailableMaps(after.mapPool, stepsAfter) const avail = computeAvailableMaps(after.mapPool, stepsAfter)
if (avail.length === 1) { if (avail.length === 1) {
await tx.mapVetoStep.update({ await tx.mapVoteStep.update({
where: { id: next.id }, where: { id: next.id },
data : { map: avail[0], chosenAt: new Date(), chosenBy: me.steamId }, data : { map: avail[0], chosenAt: new Date(), chosenBy: me.steamId },
}) })
@ -410,23 +470,23 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
const maxOrder = Math.max(...stepsAfter.map(s => s.order)) const maxOrder = Math.max(...stepsAfter.map(s => s.order))
if (idx > maxOrder) locked = true if (idx > maxOrder) locked = true
await tx.mapVeto.update({ await tx.mapVote.update({
where: { id: after.id }, where: { id: after.id },
data : { currentIdx: idx, locked }, data : { currentIdx: idx, locked },
}) })
}) })
const updated = await prisma.mapVeto.findUnique({ const updated = await prisma.mapVote.findUnique({
where : { id: veto.id }, where : { id: vote.id },
include: { steps: true }, include: { steps: true },
}) })
// 🔔 Broadcast (flat)
await sendServerSSEMessage({ type: 'map-vote-updated', matchId }) await sendServerSSEMessage({ type: 'map-vote-updated', matchId })
const teams = await buildTeamsPayload(match, req) const teams = await buildTeamsPayload(match, req)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
return NextResponse.json({ ...shapeState(updated), teams }) return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
} catch (e) { } catch (e) {
console.error('[map-vote][POST] error', e) console.error('[map-vote][POST] error', e)
return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 }) return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 })

View File

@ -12,7 +12,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) if (!me?.steamId) return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const body = await req.json().catch(() => ({})) const body = await req.json().catch(() => ({}))
const { title, matchType, teamAId, teamBId, matchDate, map, vetoLeadMinutes } = body ?? {} const { title, matchType, teamAId, teamBId, matchDate, map, voteLeadMinutes } = body ?? {}
try { try {
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
@ -20,7 +20,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
include: { include: {
teamA: { include: { leader: true } }, teamA: { include: { leader: true } },
teamB: { include: { leader: true } }, teamB: { include: { leader: true } },
mapVeto: true, mapVote: true,
}, },
}) })
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 }) if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
@ -42,7 +42,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
updateData.matchDate = matchDate ? new Date(matchDate) : null updateData.matchDate = matchDate ? new Date(matchDate) : null
} }
const lead = Number.isFinite(Number(vetoLeadMinutes)) ? Number(vetoLeadMinutes) : 60 const lead = Number.isFinite(Number(voteLeadMinutes)) ? Number(voteLeadMinutes) : 60
let opensAt: Date | null = null let opensAt: Date | null = null
if (updateData.matchDate instanceof Date) { if (updateData.matchDate instanceof Date) {
opensAt = new Date(updateData.matchDate.getTime() - lead * 60 * 1000) opensAt = new Date(updateData.matchDate.getTime() - lead * 60 * 1000)
@ -54,12 +54,12 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
const m = await tx.match.update({ const m = await tx.match.update({
where: { id }, where: { id },
data: updateData, data: updateData,
include: { mapVeto: true }, include: { mapVote: true },
}) })
if (opensAt) { if (opensAt) {
if (!m.mapVeto) { if (!m.mapVote) {
await tx.mapVeto.create({ await tx.mapVote.create({
data: { data: {
matchId: m.id, matchId: m.id,
opensAt, opensAt,
@ -67,8 +67,8 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
}, },
}) })
} else { } else {
await tx.mapVeto.update({ await tx.mapVote.update({
where: { id: m.mapVeto.id }, where: { id: m.mapVote.id },
data: { opensAt }, data: { opensAt },
}) })
} }
@ -79,7 +79,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
include: { include: {
teamA: { include: { leader: true } }, teamA: { include: { leader: true } },
teamB: { include: { leader: true } }, teamB: { include: { leader: true } },
mapVeto: true, mapVote: true,
}, },
}) })
}) })
@ -93,7 +93,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'map-vote-updated', type: 'map-vote-updated',
payload: { matchId: updated.id, opensAt: updated.mapVeto?.opensAt ?? null }, payload: { matchId: updated.id, opensAt: updated.mapVote?.opensAt ?? null },
}) })
return NextResponse.json({ return NextResponse.json({
@ -104,7 +104,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
teamBId: updated.teamBId, teamBId: updated.teamBId,
matchDate: updated.matchDate, matchDate: updated.matchDate,
map: updated.map, map: updated.map,
mapVeto: updated.mapVeto, mapVote: updated.mapVote,
}, { headers: { 'Cache-Control': 'no-store' } }) }, { headers: { 'Cache-Control': 'no-store' } })
} catch (err) { } catch (err) {
console.error(`PUT /matches/${id}/meta failed:`, err) console.error(`PUT /matches/${id}/meta failed:`, err)

View File

@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { MapVetoAction } from '@/generated/prisma' import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@ -12,24 +12,24 @@ export const dynamic = 'force-dynamic'
function buildSteps(bestOf: number, teamAId: string, teamBId: string) { function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
if (bestOf === 5) { if (bestOf === 5) {
return [ return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId }, { order: 0, action: MapVoteAction.BAN, teamId: teamAId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId }, { order: 1, action: MapVoteAction.BAN, teamId: teamBId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId }, { order: 2, action: MapVoteAction.PICK, teamId: teamAId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId }, { order: 3, action: MapVoteAction.PICK, teamId: teamBId },
{ order: 4, action: MapVetoAction.PICK, teamId: teamAId }, { order: 4, action: MapVoteAction.PICK, teamId: teamAId },
{ order: 5, action: MapVetoAction.PICK, teamId: teamBId }, { order: 5, action: MapVoteAction.PICK, teamId: teamBId },
{ order: 6, action: MapVetoAction.PICK, teamId: teamAId }, { order: 6, action: MapVoteAction.PICK, teamId: teamAId },
] as const ] as const
} }
// default BO3 // default BO3
return [ return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId }, { order: 0, action: MapVoteAction.BAN, teamId: teamAId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId }, { order: 1, action: MapVoteAction.BAN, teamId: teamBId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId }, { order: 2, action: MapVoteAction.PICK, teamId: teamAId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId }, { order: 3, action: MapVoteAction.PICK, teamId: teamBId },
{ order: 4, action: MapVetoAction.BAN, teamId: teamAId }, { order: 4, action: MapVoteAction.BAN, teamId: teamAId },
{ order: 5, action: MapVetoAction.BAN, teamId: teamBId }, { order: 5, action: MapVoteAction.BAN, teamId: teamBId },
{ order: 6, action: MapVetoAction.DECIDER, teamId: null }, { order: 6, action: MapVoteAction.DECIDER, teamId: null },
] as const ] as const
} }
@ -146,12 +146,12 @@ export async function POST (req: NextRequest) {
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true }) await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
} }
// 6) MapVeto anlegen // 6) MapVote anlegen
const baseDate = newMatch.demoDate ?? plannedAt const baseDate = newMatch.demoDate ?? plannedAt
const opensAt = new Date(baseDate.getTime() - 60 * 60 * 1000) const opensAt = new Date(baseDate.getTime() - 60 * 60 * 1000)
const stepsDef = buildSteps(bestOfInt, teamAId, teamBId) const stepsDef = buildSteps(bestOfInt, teamAId, teamBId)
await tx.mapVeto.create({ await tx.mapVote.create({
data: { data: {
matchId : newMatch.id, matchId : newMatch.id,
bestOf : bestOfInt, bestOf : bestOfInt,

View File

@ -13,7 +13,7 @@ export async function GET(req: Request) {
teamA : true, teamA : true,
teamB : true, teamB : true,
players: { include: { user: true, stats: true, team: true } }, players: { include: { user: true, stats: true, team: true } },
mapVeto: { include: { steps: true } }, mapVote: { include: { steps: true } },
}, },
}) })
@ -27,13 +27,13 @@ export async function GET(req: Request) {
let totalSteps: number | null = null let totalSteps: number | null = null
let opensInMinutes: number | null = null // <-- optional let opensInMinutes: number | null = null // <-- optional
if (m.mapVeto) { if (m.mapVote) {
const stepsSorted = [...m.mapVeto.steps].sort((a, b) => a.order - b.order) const stepsSorted = [...m.mapVote.steps].sort((a, b) => a.order - b.order)
const anyChosen = stepsSorted.some(s => !!s.chosenAt) const anyChosen = stepsSorted.some(s => !!s.chosenAt)
status = m.mapVeto.locked ? 'completed' : (anyChosen ? 'in_progress' : 'not_started') status = m.mapVote.locked ? 'completed' : (anyChosen ? 'in_progress' : 'not_started')
const computedOpensAt = const computedOpensAt =
m.mapVeto.opensAt ?? m.mapVote.opensAt ??
(() => { (() => {
const base = m.matchDate ?? m.demoDate ?? new Date() const base = m.matchDate ?? m.demoDate ?? new Date()
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
@ -47,8 +47,8 @@ export async function GET(req: Request) {
opensInMinutes = Math.max(0, Math.ceil((oa - now) / 60000)) opensInMinutes = Math.max(0, Math.ceil((oa - now) / 60000))
} }
currentIndex = m.mapVeto.currentIdx currentIndex = m.mapVote.currentIdx
const cur = stepsSorted.find(s => s.order === m.mapVeto?.currentIdx) const cur = stepsSorted.find(s => s.order === m.mapVote?.currentIdx)
currentAction = (cur?.action as 'BAN'|'PICK'|'DECIDER') ?? null currentAction = (cur?.action as 'BAN'|'PICK'|'DECIDER') ?? null
decidedCount = stepsSorted.filter(s => !!s.chosenAt).length decidedCount = stepsSorted.filter(s => !!s.chosenAt).length
totalSteps = stepsSorted.length totalSteps = stepsSorted.length
@ -68,7 +68,7 @@ export async function GET(req: Request) {
scoreB : m.scoreB, scoreB : m.scoreB,
winnerTeam: m.winnerTeam ?? null, winnerTeam: m.winnerTeam ?? null,
mapVeto: m.mapVeto ? { mapVote: m.mapVote ? {
status, status,
opensAt: opensAtISO, opensAt: opensAtISO,
isOpen, isOpen,

View File

@ -10,9 +10,9 @@ type ButtonProps = {
modalId?: string modalId?: string
color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'transparent' color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'transparent'
variant?: 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link' variant?: 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link'
size?: 'xs' |'sm' | 'md' | 'lg' size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
className?: string className?: string
dropDirection?: "up" | "down" | "auto" dropDirection?: 'up' | 'down' | 'auto'
disabled?: boolean disabled?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement> } & ButtonHTMLAttributes<HTMLButtonElement>
@ -27,7 +27,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
variant = 'solid', variant = 'solid',
size = 'md', size = 'md',
className, className,
dropDirection = "down", dropDirection = 'down',
disabled = false, disabled = false,
...rest ...rest
}, },
@ -52,12 +52,14 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
sm: 'py-2 px-3', sm: 'py-2 px-3',
md: 'py-3 px-4', md: 'py-3 px-4',
lg: 'p-4 sm:p-5', lg: 'p-4 sm:p-5',
xl: 'py-6 px-8 text-lg',
full: 'py-6 px-8 text-lg w-full',
} }
const base = ` const base = `
${sizeClasses[size] || sizeClasses['md']} ${sizeClasses[size] || sizeClasses['md']}
inline-flex items-center gap-x-2 text-sm font-medium rounded-lg inline-flex items-center gap-x-2 text-sm font-medium rounded-lg
focus:outline-hidden disabled:opacity-50 disabled:pointer-events-none focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed
` `
const variants: Record<string, Record<string, string>> = { const variants: Record<string, Record<string, string>> = {
@ -83,7 +85,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
gray: 'border border-transparent text-gray-600 hover:bg-gray-100 hover:text-gray-800 focus:bg-gray-100 focus:text-gray-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white', gray: 'border border-transparent text-gray-600 hover:bg-gray-100 hover:text-gray-800 focus:bg-gray-100 focus:text-gray-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white',
teal: 'border border-transparent text-teal-600 hover:bg-teal-100 hover:text-teal-800 focus:bg-teal-100 focus:text-teal-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white', teal: 'border border-transparent text-teal-600 hover:bg-teal-100 hover:text-teal-800 focus:bg-teal-100 focus:text-teal-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white',
green: 'border border-transparent text-green-600 hover:bg-green-100 hover:text-green-800 focus:bg-green-100 focus:text-green-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white', green: 'border border-transparent text-green-600 hover:bg-green-100 hover:text-green-800 focus:bg-green-100 focus:text-green-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white',
transparent: 'border border-transparent text-transparent-600 hover:bg-transparent-100 hover:text-transparent-800 focus:bg-transparent-100 focus:text-transparent-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white', transparent: 'border border-transparent text-transparent-600 hover:bg-transparent-100 focus:bg-transparent-100 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-white dark:focus:bg-neutral-700 dark:focus:text-white',
}, },
soft: { soft: {
blue: 'bg-blue-100 text-blue-800 hover:bg-blue-200 focus:bg-blue-200 dark:text-blue-400 dark:hover:bg-blue-900 dark:focus:bg-blue-900', blue: 'bg-blue-100 text-blue-800 hover:bg-blue-200 focus:bg-blue-200 dark:text-blue-400 dark:hover:bg-blue-900 dark:focus:bg-blue-900',
@ -107,39 +109,50 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
gray: 'border border-transparent text-gray-600 hover:text-gray-800 focus:text-gray-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white', gray: 'border border-transparent text-gray-600 hover:text-gray-800 focus:text-gray-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white',
teal: 'border border-transparent text-teal-600 hover:text-teal-800 focus:text-teal-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white', teal: 'border border-transparent text-teal-600 hover:text-teal-800 focus:text-teal-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white',
green: 'border border-transparent text-green-600 hover:text-green-800 focus:text-green-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white', green: 'border border-transparent text-green-600 hover:text-green-800 focus:text-green-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white',
transparent: 'border border-transparent text-transparent-600 hover:text-transparent-800 focus:text-transparent-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white' transparent: 'border border-transparent text-transparent-600 hover:text-transparent-800 focus:text-transparent-800 dark:text-neutral-400 dark:hover:text-white dark:focus:text-white',
}, },
} }
const variantClasses = variants[variant]?.[color] || variants.solid.blue
// Entfernt alle Hover/Focus/Active Tokens (inkl. dark:hover:..., sm:focus:..., etc.)
const stripInteractive = (cls: string) =>
cls
.split(/\s+/)
.filter(c => c && !c.includes('hover:') && !c.includes('focus:') && !c.includes('active:'))
.join(' ')
const safeVariantClasses = disabled ? stripInteractive(variantClasses) : variantClasses
const classes = ` const classes = `
${base} ${base}
${variants[variant]?.[color] || variants.solid.blue} ${safeVariantClasses}
${className || ''} ${className || ''}
` `
useEffect(() => { useEffect(() => {
if (open && dropDirection === "auto" && buttonRef.current) { if (open && dropDirection === 'auto' && buttonRef.current) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const rect = buttonRef.current!.getBoundingClientRect(); const rect = buttonRef.current!.getBoundingClientRect()
const dropdownHeight = 200; const dropdownHeight = 200
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top; const spaceAbove = rect.top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) { if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
setDirection("up"); setDirection('up')
} else { } else {
setDirection("down"); setDirection('down')
} }
}); })
} }
}, [open, dropDirection]); }, [open, dropDirection])
const toggle = (event: React.MouseEvent<HTMLButtonElement>) => { const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
const next = !open const next = !open
setOpen(next) setOpen(next)
onToggle?.(next) onToggle?.(next)
onClick?.(event) onClick?.(event)
} }
return ( return (
<button <button
@ -147,6 +160,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
type="button" type="button"
className={classes} className={classes}
onClick={toggle} onClick={toggle}
disabled={disabled}
{...modalAttributes} {...modalAttributes}
{...rest} {...rest}
> >

View File

@ -247,7 +247,7 @@ export default function CommunityMatchList({ matchType }: Props) {
<div className="bg-yellow-300 dark:bg-yellow-500 text-center py-2 font-bold tracking-wider"> <div className="bg-yellow-300 dark:bg-yellow-500 text-center py-2 font-bold tracking-wider">
{dayLabel}<br />{dateKey} {dayLabel}<br />{dateKey}
</div> </div>
{dayMatches.map(m => { {dayMatches.map((m: Match) => {
const started = new Date(m.demoDate).getTime() <= Date.now() const started = new Date(m.demoDate).getTime() <= Date.now()
const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null
const isLive = started && unfinished const isLive = started && unfinished
@ -273,23 +273,23 @@ export default function CommunityMatchList({ matchType }: Props) {
</span> </span>
)} )}
{/* Map-Veto Badge */} {/* Map-Vote Badge */}
{m.mapVeto && ( {m.mapVote && (
<span <span
className={` className={`
px-2 py-0.5 rounded-full text-[11px] font-semibold px-2 py-0.5 rounded-full text-[11px] font-semibold
${m.mapVeto.isOpen ? 'bg-green-300 dark:bg-green-600 text-white' : 'bg-neutral-200 dark:bg-neutral-700'} ${m.mapVote.isOpen ? 'bg-green-300 dark:bg-green-600 text-white' : 'bg-neutral-200 dark:bg-neutral-700'}
`} `}
title={ title={
m.mapVeto.opensAt m.mapVote.opensAt
? `Öffnet ${format(new Date(m.mapVeto.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr` ? `Öffnet ${format(new Date(m.mapVote.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr`
: undefined : undefined
} }
> >
{m.mapVeto.isOpen {m.mapVote.isOpen
? (m.mapVeto.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen') ? (m.mapVote.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen')
: m.mapVeto.opensAt : m.mapVote.opensAt
? `Map-Vote ab ${format(new Date(m.mapVeto.opensAt), 'HH:mm', { locale: de })} Uhr` ? `Map-Vote ab ${format(new Date(m.mapVote.opensAt), 'HH:mm', { locale: de })} Uhr`
: 'Map-Vote bald'} : 'Map-Vote bald'}
</span> </span>
)} )}

View File

@ -20,7 +20,7 @@ type Props = {
defaultTeamBName?: string | null defaultTeamBName?: string | null
defaultDateISO?: string | null defaultDateISO?: string | null
defaultMap?: string | null defaultMap?: string | null
defaultVetoLeadMinutes?: number defaultVoteLeadMinutes?: number
onSaved?: () => void onSaved?: () => void
} }
@ -35,7 +35,7 @@ export default function EditMatchMetaModal({
defaultTeamBName, defaultTeamBName,
defaultDateISO, defaultDateISO,
defaultMap, defaultMap,
defaultVetoLeadMinutes = 60, defaultVoteLeadMinutes = 60,
onSaved, onSaved,
}: Props) { }: Props) {
// -------- state // -------- state
@ -48,8 +48,8 @@ export default function EditMatchMetaModal({
const pad = (n: number) => String(n).padStart(2, '0') 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())}` 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 [mapKey, setMapKey] = useState<string>(defaultMap ?? 'lobby_mapvote')
const [vetoLead, setVetoLead] = useState<number>(defaultVetoLeadMinutes) const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
const [teams, setTeams] = useState<TeamOption[]>([]) const [teams, setTeams] = useState<TeamOption[]>([])
const [loadingTeams, setLoadingTeams] = useState(false) const [loadingTeams, setLoadingTeams] = useState(false)
@ -83,8 +83,8 @@ export default function EditMatchMetaModal({
setTitle(defaultTitle ?? '') setTitle(defaultTitle ?? '')
setTeamAId(defaultTeamAId ?? '') setTeamAId(defaultTeamAId ?? '')
setTeamBId(defaultTeamBId ?? '') setTeamBId(defaultTeamBId ?? '')
setMapKey(defaultMap ?? 'lobby_mapveto') setMapKey(defaultMap ?? 'lobby_mapvote')
setVetoLead(defaultVetoLeadMinutes) setVoteLead(defaultVoteLeadMinutes)
if (defaultDateISO) { if (defaultDateISO) {
const d = new Date(defaultDateISO) const d = new Date(defaultDateISO)
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
@ -101,7 +101,7 @@ export default function EditMatchMetaModal({
defaultTeamBId, defaultTeamBId,
defaultDateISO, defaultDateISO,
defaultMap, defaultMap,
defaultVetoLeadMinutes, defaultVoteLeadMinutes,
]) ])
// -------- derived: options // -------- derived: options
@ -143,7 +143,7 @@ export default function EditMatchMetaModal({
teamBId: teamBId || null, teamBId: teamBId || null,
matchDate: date ? new Date(date).toISOString() : null, matchDate: date ? new Date(date).toISOString() : null,
map: mapKey || null, map: mapKey || null,
vetoLeadMinutes: Number.isFinite(Number(vetoLead)) ? Number(vetoLead) : 60, voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
} }
const res = await fetch(`/api/matches/${matchId}/meta`, { const res = await fetch(`/api/matches/${matchId}/meta`, {
@ -253,18 +253,18 @@ export default function EditMatchMetaModal({
/> />
</div> </div>
{/* Veto-Lead */} {/* Vote-Lead */}
<div> <div>
<label className="block text-sm font-medium mb-1">Map-Veto lead (Minuten)</label> <label className="block text-sm font-medium mb-1">Map-Vote lead (Minuten)</label>
<input <input
type="number" type="number"
min={0} min={0}
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50" className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
value={vetoLead} value={voteLead}
onChange={e => setVetoLead(Number(e.target.value))} onChange={e => setVoteLead(Number(e.target.value))}
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Zeit vor Matchstart, zu der das Veto öffnet (Standard 60). Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,14 +1,14 @@
// MapVetoBanner.tsx // MapVoteBanner.tsx
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import type { MapVetoState } from '../types/mapveto' import type { MapVoteState } from '../types/mapvote'
type Props = { match: any; initialNow: number } type Props = { match: any; initialNow: number }
export default function MapVetoBanner({ match, initialNow }: Props) { export default function MapVoteBanner({ match, initialNow }: Props) {
const router = useRouter() const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
@ -16,7 +16,7 @@ export default function MapVetoBanner({ match, initialNow }: Props) {
// ✅ eine Uhr, deterministisch bei Hydration (kommt als Prop vom Server) // ✅ eine Uhr, deterministisch bei Hydration (kommt als Prop vom Server)
const [now, setNow] = useState(initialNow) const [now, setNow] = useState(initialNow)
const [state, setState] = useState<MapVetoState | null>(null) const [state, setState] = useState<MapVoteState | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
@ -130,7 +130,7 @@ export default function MapVetoBanner({ match, initialNow }: Props) {
<div className="shrink-0"> <div className="shrink-0">
{state?.locked ? ( {state?.locked ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200"> <span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
Veto abgeschlossen Voting abgeschlossen
</span> </span>
) : isOpen ? ( ) : isOpen ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200"> <span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">

View File

@ -1,16 +1,18 @@
// /app/components/MapVetoPanel.tsx // /app/components/MapVotePanel.tsx
'use client' 'use client'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import type React from 'react' import type React from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import { MAP_OPTIONS } from '../lib/mapOptions' import MapVoteProfileCard from './MapVoteProfileCard'
import MapVoteProfileCard from './MapVetoProfileCard'
import type { Match, MatchPlayer } from '../types/match' import type { Match, MatchPlayer } from '../types/match'
import type { MapVetoState } from '../types/mapveto' import type { MapVoteState } from '../types/mapvote'
import TeamPremierRankBadge from './TeamPremierRankBadge'
import Button from './Button' import Button from './Button'
import { Player } from '../types/team' import Image from 'next/image'
import LoadingSpinner from './LoadingSpinner'
type Props = { match: Match } type Props = { match: Match }
@ -20,13 +22,15 @@ const getTeamLogo = (logo?: string | null) =>
const HOLD_MS = 1200 const HOLD_MS = 1200
const COMPLETE_THRESHOLD = 1.0 const COMPLETE_THRESHOLD = 1.0
export default function MapVetoPanel({ match }: Props) { export default function MapVotePanel({ match }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const router = useRouter()
const [state, setState] = useState<MapVetoState | null>(null) const [state, setState] = useState<MapVoteState | null>(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [adminEditMode, setAdminEditMode] = useState(false)
// --- Zeitpunkt: 1h vor Match-/Demo-Beginn (Fallback) --- // --- Zeitpunkt: 1h vor Match-/Demo-Beginn (Fallback) ---
const opensAtTs = useMemo(() => { const opensAtTs = useMemo(() => {
@ -48,16 +52,20 @@ export default function MapVetoPanel({ match }: Props) {
const isLeaderA = !!me?.steamId && match.teamA?.leader?.steamId === me.steamId const isLeaderA = !!me?.steamId && match.teamA?.leader?.steamId === me.steamId
const isLeaderB = !!me?.steamId && match.teamB?.leader?.steamId === me.steamId const isLeaderB = !!me?.steamId && match.teamB?.leader?.steamId === me.steamId
// Admin-Freeze ableiten
const adminEditingBy = state?.adminEdit?.by ?? null
const adminEditingEnabled = !!state?.adminEdit?.enabled
const isFrozenByAdmin = adminEditingEnabled && adminEditingBy !== me?.steamId
const canActForTeamId = useCallback( const canActForTeamId = useCallback(
(teamId?: string | null) => { (teamId?: string | null) => {
if (!teamId) return false if (!teamId) return false
if (isAdmin) return true
return ( return (
(teamId === match.teamA?.id && isLeaderA) || (teamId === match.teamA?.id && isLeaderA) ||
(teamId === match.teamB?.id && isLeaderB) (teamId === match.teamB?.id && isLeaderB)
) )
}, },
[isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id], [isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id],
) )
// --- Laden / Reload --- // --- Laden / Reload ---
@ -83,19 +91,23 @@ export default function MapVetoPanel({ match }: Props) {
} }
}, [match.id]) }, [match.id])
useEffect(() => { useEffect(() => { load() }, [load])
load()
}, [load])
// --- SSE: live nachladen --- // --- SSE: live nachladen ---
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
if (lastEvent.type !== 'map-vote-updated') return if (lastEvent.type !== 'map-vote-updated' && lastEvent.type !== 'map-vote-admin-edit') return
const matchId = lastEvent.payload?.matchId const matchId = lastEvent.payload?.matchId
if (matchId !== match.id) return if (matchId !== match.id) return
load() load()
}, [lastEvent, match.id, load]) }, [lastEvent, match.id, load])
// --- Admin-Edit lokalen Toggle an globalem Zustand spiegeln ---
useEffect(() => {
const iAmEditing = adminEditingEnabled && adminEditingBy === me?.steamId
setAdminEditMode(iAmEditing)
}, [adminEditingEnabled, adminEditingBy, me?.steamId])
// --- Abgeleitet --- // --- Abgeleitet ---
const opensAt = useMemo( const opensAt = useMemo(
() => (state?.opensAt ? new Date(state.opensAt).getTime() : null), () => (state?.opensAt ? new Date(state.opensAt).getTime() : null),
@ -106,7 +118,11 @@ export default function MapVetoPanel({ match }: Props) {
const currentStep = state?.steps?.[state?.currentIndex ?? 0] const currentStep = state?.steps?.[state?.currentIndex ?? 0]
const isMyTurn = Boolean( const isMyTurn = Boolean(
isOpen && !state?.locked && currentStep?.teamId && canActForTeamId(currentStep.teamId), isOpen &&
!state?.locked &&
!isFrozenByAdmin &&
currentStep?.teamId &&
(canActForTeamId(currentStep.teamId) || (isAdmin && adminEditMode))
) )
const mapPool = state?.mapPool ?? [] const mapPool = state?.mapPool ?? []
@ -120,7 +136,7 @@ export default function MapVetoPanel({ match }: Props) {
return map return map
}, [state?.steps]) }, [state?.steps])
const fmt = (k: string) => MAP_OPTIONS.find((m) => m.key === k)?.label ?? k const fmt = (k: string) => state?.mapVisuals?.[k]?.label ?? k
// --- Aktionen --- // --- Aktionen ---
const handlePickOrBan = async (map: string) => { const handlePickOrBan = async (map: string) => {
@ -137,7 +153,7 @@ export default function MapVetoPanel({ match }: Props) {
return return
} }
// ⬅️ Optimistisches Update, bevor SSE kommt: // Optimistisches Update
setState(prev => setState(prev =>
prev prev
? { ? {
@ -148,12 +164,25 @@ export default function MapVetoPanel({ match }: Props) {
} }
: prev : prev
) )
} catch { } catch {
alert('Netzwerkfehler') alert('Netzwerkfehler')
} }
} }
// --- Admin-Edit global toggeln ---
async function postAdminEdit(enabled: boolean) {
const r = await fetch(`/api/matches/${match.id}/mapvote/admin-edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!r.ok) {
const j = await r.json().catch(() => ({}))
throw new Error(j?.message || 'Konnte Admin-Edit nicht setzen')
}
return r.json()
}
// --- Press-and-hold Logik (pro Map) --- // --- Press-and-hold Logik (pro Map) ---
const rafRef = useRef<number | null>(null) const rafRef = useRef<number | null>(null)
const holdStartRef = useRef<number | null>(null) const holdStartRef = useRef<number | null>(null)
@ -249,11 +278,9 @@ export default function MapVetoPanel({ match }: Props) {
// --- Spielerlisten ableiten (Hooks bleiben IMMER aktiv) --- // --- Spielerlisten ableiten (Hooks bleiben IMMER aktiv) ---
const playersA = useMemo<MatchPlayer[]>(() => { const playersA = useMemo<MatchPlayer[]>(() => {
// 0) Bevorzugt: bereits vorbereitete Team-Spieler am Match selbst
const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
// 1) Klassischer Weg: match.players via Roster (teamAUsers) filtern
const all = (match as any).players as MatchPlayer[] | undefined const all = (match as any).players as MatchPlayer[] | undefined
const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined
if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) { if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) {
@ -261,34 +288,28 @@ export default function MapVetoPanel({ match }: Props) {
return all.filter(p => setA.has(p.user.steamId)) return all.filter(p => setA.has(p.user.steamId))
} }
// 2) Fallback: teamId am Player (falls vorhanden)
if (Array.isArray(all) && match.teamA?.id) { if (Array.isArray(all) && match.teamA?.id) {
return all.filter(p => (p as any).team?.id === 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 votePlayers = state?.teams?.teamA?.players as
const vetoPlayers = state?.teams?.teamA?.players as
| Array<{ steamId: string; name?: string | null; avatar?: string | null }> | Array<{ steamId: string; name?: string | null; avatar?: string | null }>
| undefined | undefined
if (Array.isArray(vetoPlayers) && vetoPlayers.length) { if (Array.isArray(votePlayers) && votePlayers.length) {
return vetoPlayers.map((p): MatchPlayer => ({ return votePlayers.map((p): MatchPlayer => ({
user: { user: {
steamId: p.steamId, steamId: p.steamId,
name: p.name ?? 'Unbekannt', name: p.name ?? 'Unbekannt',
avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg', avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
}, },
// wichtig: undefined statt null
stats: undefined, stats: undefined,
// falls dein MatchPlayer einen string akzeptiert:
// team: (match as any)?.teamA?.name ?? 'Team A',
})) }))
} }
return [] return []
}, [match, state?.teams?.teamA?.players]) }, [match, state?.teams?.teamA?.players])
// ⬇️ ersetzt den bisherigen playersB-Block
const playersB = useMemo<MatchPlayer[]>(() => { const playersB = useMemo<MatchPlayer[]>(() => {
const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
@ -304,95 +325,170 @@ export default function MapVetoPanel({ match }: Props) {
return all.filter(p => (p as any).team?.id === match.teamB?.id) return all.filter(p => (p as any).team?.id === match.teamB?.id)
} }
const vetoPlayers = state?.teams?.teamB?.players as const votePlayers = state?.teams?.teamB?.players as
| Array<{ steamId: string; name?: string | null; avatar?: string | null }> | Array<{ steamId: string; name?: string | null; avatar?: string | null }>
| undefined | undefined
if (Array.isArray(vetoPlayers) && vetoPlayers.length) { if (Array.isArray(votePlayers) && votePlayers.length) {
return vetoPlayers.map((p): MatchPlayer => ({ return votePlayers.map((p): MatchPlayer => ({
user: { user: {
steamId: p.steamId, steamId: p.steamId,
name: p.name ?? 'Unbekannt', name: p.name ?? 'Unbekannt',
avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg', avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
}, },
stats: undefined, stats: undefined,
// team: (match as any)?.teamB?.name ?? 'Team B',
})) }))
} }
return [] return []
}, [match, state?.teams?.teamB?.players]) }, [match, state?.teams?.teamB?.players])
const teamAPlayersForRank = useMemo(
() => playersA.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any,
[playersA]
)
const teamBPlayersForRank = useMemo(
() => playersB.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any,
[playersB]
)
// --- kleine Helpers ---
const editingDisplayName = useMemo(() => {
if (!adminEditingBy) return null
const all: Array<{ steamId: string; name?: string | null }> = []
const pushMaybe = (x: any) => { if (x?.steamId) all.push({ steamId: x.steamId, name: x.name }) }
pushMaybe(state?.teams?.teamA?.leader)
pushMaybe(state?.teams?.teamB?.leader)
;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe)
;(state?.teams?.teamB?.players ?? []).forEach(pushMaybe)
return all.find(p => p.steamId === adminEditingBy)?.name || 'Admin'
}, [adminEditingBy, state?.teams])
const showLoading = isLoading && !state const showLoading = isLoading && !state
const showError = !!error && !state const showError = !!error && !state
const sortedMapPool = useMemo(() => {
// nach Anzeige-Label sortieren (fallback: key), case-insensitive, deutsch
return [...(state?.mapPool ?? [])].sort((a, b) =>
(state?.mapVisuals?.[a]?.label ?? a)
.localeCompare(state?.mapVisuals?.[b]?.label ?? b, 'de', { sensitivity: 'base' })
)
}, [state?.mapPool, state?.mapVisuals])
// --- UI ---
return ( return (
<div className="p-4"> <div className="p-4">
{showLoading ? ( {showLoading ? (
<div className="p-4">Lade Map-Voting</div> <div className="p-4">
<LoadingSpinner />
</div>
) : showError ? ( ) : showError ? (
<div className="p-4 text-red-600">{error}</div> <div className="p-4 text-red-600">{error}</div>
) : ( ) : (
<> <>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-3"> <div className="grid grid-cols-3 items-center mb-3">
<h3 className="text-lg font-semibold">Map-Vote</h3> {/* Linke Spalte */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Button
color="gray"
variant="outline"
size="sm"
onClick={() => router.push(`/match-details/${match.id}`)}
>
Zurück
</Button>
</div>
{/* Mittlere Spalte (zentriert) */}
<h3 className="text-lg font-semibold text-center">Voting</h3>
{/* Rechte Spalte */}
<div className="flex justify-end items-center gap-2">
<div className="text-sm opacity-80"> <div className="text-sm opacity-80">
Modus: BO{match.bestOf ?? state?.bestOf ?? 3} Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
</div> </div>
{isAdmin && ( {isAdmin && (
<Button <>
color="red" <Button
variant="outline" color={adminEditMode ? 'teal' : 'gray'}
size="sm" variant={adminEditMode ? 'solid' : 'outline'}
className="ml-3" size="sm"
title="Map-Vote zurücksetzen" className="ml-2"
onClick={async () => { title={adminEditMode ? 'Admin-Bearbeitung beenden' : 'Map-Vote als Admin bearbeiten'}
if (!confirm('Map-Vote wirklich zurücksetzen? Alle bisherigen Picks/Bans gehen verloren.')) return onClick={async () => {
try { // Optimistisch lokal toggeln
const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' }) const next = !adminEditMode
if (!r.ok) { setAdminEditMode(next)
const j = await r.json().catch(() => ({})) try {
alert(j.message ?? 'Reset fehlgeschlagen') await postAdminEdit(next) // globaler Freeze on/off
return await load()
} catch (e: any) {
setAdminEditMode(v => !v) // rollback
alert(e?.message ?? 'Fehler beim Umschalten des Admin-Edits')
} }
// SSE feuert ohnehin; zusätzlich lokal nachladen: }}
await load() >
} catch { {adminEditMode ? 'Bearbeiten: AN' : 'Bearbeiten'}
alert('Netzwerkfehler beim Reset') </Button>
}
}} <Button
/> color="red"
variant="outline"
size="sm"
className="ml-2"
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
}
await load()
} catch {
alert('Netzwerkfehler beim Reset')
}
}}
>
Reset
</Button>
</>
)} )}
</div> </div>
</div> </div>
{/* Countdown / Status */} {/* Countdown / Status */}
{!isOpen && ( <div className="mb-4 flex flex-col items-center gap-2">
<div className="mb-4 text-sm">
<span className="inline-block px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100">
Öffnet in {formatCountdown(msToOpen)}
</span>
</div>
)}
{/* Countdown / Status ganz oben und größer */}
<div className="mb-4 flex justify-center">
{state?.locked ? ( {state?.locked ? (
<span className="block text-lg font-semibold px-3 py-2 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 text-center"> <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 Voting abgeschlossen
</span> </span>
) : isOpen ? ( ) : isOpen ? (
isMyTurn ? ( isFrozenByAdmin ? (
<span className="block text-lg font-semibold px-3 py-2 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100 text-center">
🔒 Admin-Edit aktiv Voting pausiert
{editingDisplayName ? ` (von ${editingDisplayName})` : ''}
</span>
) : 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"> <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 {currentStep?.action === 'ban'
? '🚫 Dein Team darf bannen'
: currentStep?.action === 'pick'
? '✅ Dein Team darf picken'
: 'Du bist dran'}
</span> </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"> <span className="block text-lg font-semibold px-3 py-2 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200 text-center">
Wartet auf&nbsp; Wartet auf&nbsp;
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name} {currentStep?.teamId === match.teamA?.id
&nbsp;(Leader/Admin) ? match.teamA.name
: match.teamB.name}
</span> </span>
) )
) : ( ) : (
@ -402,7 +498,7 @@ export default function MapVetoPanel({ match }: Props) {
)} )}
{error && ( {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"> <span className="block 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} {error}
</span> </span>
)} )}
@ -410,9 +506,25 @@ export default function MapVetoPanel({ match }: Props) {
{/* Hauptbereich */} {/* Hauptbereich */}
{state && ( {state && (
<div className="mt-2 flex items-start gap-4 justify-between"> <div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start">
{/* Links Team A */} {/* Linke Spalte Team A */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56"> <div className="flex flex-col items-start gap-3">
{/* Teamkopf A */}
<div className="flex items-center gap-3">
<img
src={getTeamLogo(match.teamA?.logo)}
alt={match.teamA?.name ?? 'Team A'}
className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain"
width={12}
height={12}
/>
<div className="min-w-0">
<div className="font-bold text-lg truncate">{match.teamA?.name ?? 'Team A'}</div>
</div>
<TeamPremierRankBadge players={teamAPlayersForRank} />
</div>
{/* Spieler A */}
{playersA.map((p: MatchPlayer) => ( {playersA.map((p: MatchPlayer) => (
<MapVoteProfileCard <MapVoteProfileCard
key={p.user.steamId} key={p.user.steamId}
@ -421,6 +533,7 @@ export default function MapVetoPanel({ match }: Props) {
avatar={p.user.avatar} avatar={p.user.avatar}
rank={p.stats?.rankNew ?? 0} rank={p.stats?.rankNew ?? 0}
matchType={match.matchType} matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={ isLeader={
(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) === (state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) ===
p.user.steamId p.user.steamId
@ -432,12 +545,12 @@ export default function MapVetoPanel({ match }: Props) {
} }
/> />
))} ))}
</aside> </div>
{/* Mitte Maps (Hold-to-confirm) */} {/* Mitte Mappool */}
<main className="max-w-sm flex-shrink-0"> <main className="w-full flex-1 max-w-xl">
<ul className="flex flex-col gap-1.5"> <ul className="flex flex-col gap-3">
{mapPool.map((map) => { {sortedMapPool.map((map) => {
const decision = decisionByMap.get(map) const decision = decisionByMap.get(map)
const status = decision?.action ?? null // 'ban' | 'pick' | 'decider' | null const status = decision?.action ?? null // 'ban' | 'pick' | 'decider' | null
const teamId = decision?.teamId ?? null const teamId = decision?.teamId ?? null
@ -449,28 +562,13 @@ export default function MapVetoPanel({ match }: Props) {
const intent = isAvailable ? currentStep?.action : null const intent = isAvailable ? currentStep?.action : null
const intentStyles = const intentStyles =
intent === 'ban' intent === 'ban'
? { ? { hover: 'hover:bg-red-50 dark:hover:bg-red-950', progress: 'bg-red-200/60 dark:bg-red-800/40' }
ring: '',
border: '',
hover: 'hover:bg-red-50 dark:hover:bg-red-950',
progress: 'bg-red-200/60 dark:bg-red-800/40',
}
: intent === 'pick' : intent === 'pick'
? { ? { hover: 'hover:bg-green-50 dark:hover:bg-green-950', progress: 'bg-green-200/60 dark:bg-green-800/40' }
ring: '', : { hover: 'hover:bg-blue-50 dark:hover:bg-blue-950', progress: 'bg-blue-200/60 dark:bg-blue-800/40' }
border: '',
hover: 'hover:bg-green-50 dark:hover:bg-green-950',
progress: 'bg-green-200/60 dark:bg-green-800/40',
}
: {
ring: '',
border: '',
hover: 'hover:bg-blue-50 dark:hover:bg-blue-950',
progress: 'bg-blue-200/60 dark:bg-blue-800/40',
}
const baseClasses = const baseClasses =
'relative flex items-center justify-between gap-2 rounded-md border p-2.5 transition select-none' 'relative flex items-center justify-between gap-2 rounded-md border border-neutral-500 p-2.5 transition select-none'
const visualTaken = const visualTaken =
status === 'ban' status === 'ban'
@ -479,8 +577,8 @@ export default function MapVetoPanel({ match }: Props) {
? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40' ? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40'
: 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700' : 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700'
const visualAvailable = `bg-white dark:bg-neutral-900 ${intentStyles.border} ring-1 ${intentStyles.ring} ${intentStyles.hover} cursor-pointer` const visualAvailable = `bg-white dark:bg-neutral-900 ${intentStyles.hover} cursor-pointer`
const visualDisabled = 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700' const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
// Decider-Team bestimmen (falls nötig) // Decider-Team bestimmen (falls nötig)
@ -497,26 +595,44 @@ export default function MapVetoPanel({ match }: Props) {
const progress = progressByMap[map] ?? 0 const progress = progressByMap[map] ?? 0
const showProgress = isAvailable && progress > 0 && progress < 1 const showProgress = isAvailable && progress > 0 && progress < 1
const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg`
const disabledTitle = isFrozenByAdmin
? 'Ein Admin bearbeitet gerade Voting gesperrt'
: 'Nur der Team-Leader (oder Admin) darf wählen'
return ( return (
<li <li
key={map} key={map}
className="grid grid-cols-[24px_1fr_24px] items-center gap-2" className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2"
> >
{/* linker Slot */} {/* linker Slot */}
{pickedByA ? ( {pickedByA ? (
<img <img
src={getTeamLogo(match.teamA?.logo)} src={getTeamLogo(match.teamA?.logo)}
alt={match.teamA?.name ?? 'Team A'} alt={match.teamA?.name ?? 'Team A'}
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900" className={[
"w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain",
"transition-opacity transition-transform duration-300 ease-out transform-gpu will-change-transform",
pickedByA ? "opacity-100 scale-100" : "opacity-0 scale-90 pointer-events-none"
].join(" ")}
/> />
) : ( ) : (
<div className="w-6 h-6" /> <div className="w-10 h-10" />
)} )}
{/* Button */} {/* Button */}
<Button <Button
className={`${baseClasses} ${visualClasses} w-full text-left relative`} variant="link"
color="transparent"
className={[
baseClasses,
visualClasses,
"w-full text-left relative overflow-hidden group",
"transition-colors duration-300 ease-in-out"
].join(" ")}
disabled={!isAvailable} disabled={!isAvailable}
size="full"
title={ title={
taken taken
? status === 'ban' ? status === 'ban'
@ -526,7 +642,7 @@ export default function MapVetoPanel({ match }: Props) {
: 'Decider' : 'Decider'
: isAvailable : isAvailable
? 'Zum Bestätigen gedrückt halten' ? 'Zum Bestätigen gedrückt halten'
: 'Nur der Team-Leader (oder Admin) darf wählen' : disabledTitle
} }
onMouseDown={() => onHoldStart(map, isAvailable)} onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)} onMouseUp={() => cancelOrSubmitIfComplete(map)}
@ -535,27 +651,104 @@ export default function MapVetoPanel({ match }: Props) {
onTouchEnd={onTouchEnd(map)} onTouchEnd={onTouchEnd(map)}
onTouchCancel={onTouchEnd(map)} onTouchCancel={onTouchEnd(map)}
> >
<div
className="absolute inset-0 bg-center bg-auto filter opacity-30 transition-opacity duration-300"
style={{ backgroundImage: `url('${bg}')` }}
/>
{/* Fortschrittsbalken */} {/* Fortschrittsbalken */}
{showProgress && ( {showProgress && (
<span <span
aria-hidden aria-hidden
className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-0`} className={`absolute inset-y-0 left-0 rounded-md ${intentStyles.progress} pointer-events-none z-10`}
style={{ width: `${Math.round(progress * 100)}%` }} style={{ width: `${Math.round(progress * 100)}%` }}
/> />
)} )}
{/* Inhalt */} {/* Fixe Ban/Pick-Pills bei bereits entschiedenen Maps (inkl. Decider = Pick) */}
<div className="flex-1 min-w-0 relative z-[1] flex flex-col items-center justify-center text-center"> {taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
<span className="text-[13px] font-medium truncate">{fmt(map)}</span> <>
{/* linke Seite (Team A) */}
{(
(status === 'ban' && teamId === match.teamA?.id) ||
(status === 'pick' && effectiveTeamId === match.teamA?.id) ||
(status === 'decider' && effectiveTeamId === match.teamA?.id)
) && (
<span
className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2
px-2 py-0.5 text-[11px] font-semibold rounded transition duration-300 ease-out
${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`}
style={{ zIndex: 25 }}
>
{status === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
{/* rechte Seite (Team B) */}
{(
(status === 'ban' && teamId === match.teamB?.id) ||
(status === 'pick' && effectiveTeamId === match.teamB?.id) ||
(status === 'decider' && effectiveTeamId === match.teamB?.id)
) && (
<span
className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2
px-2 py-0.5 text-[11px] font-semibold rounded
${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`}
style={{ zIndex: 25 }}
>
{status === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
</>
)}
{/* Hover-Ban/Pick-Pills */}
{isAvailable && (intent === 'ban' || intent === 'pick') && (
<>
{/* Team A (links) */}
{currentStep?.teamId === match.teamA?.id && (
<span
className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2
px-2 py-0.5 text-[11px] font-semibold rounded
opacity-0 group-hover:opacity-100 transition
${intent === 'ban'
? 'bg-red-600 text-white'
: 'bg-green-600 text-white'}`}
style={{ zIndex: 25 }}
>
{intent === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
{/* Team B (rechts) */}
{currentStep?.teamId === match.teamB?.id && (
<span
className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2
px-2 py-0.5 text-[11px] font-semibold rounded
opacity-0 group-hover:opacity-100 transition
${intent === 'ban'
? 'bg-red-600 text-white'
: 'bg-green-600 text-white'}`}
style={{ zIndex: 25 }}
>
{intent === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
</>
)}
{/* Button-Inhalt */}
<div className="flex-1 min-w-0 relative z-20 flex flex-col items-center justify-center text-center">
<span className="text-[13px] font-medium truncate text-white font-semibold uppercase">{fmt(map)}</span>
{status === 'ban' && ( {status === 'ban' && (
<span <span
aria-hidden aria-hidden
className="absolute inset-0 pointer-events-none flex items-center justify-center z-[2]" className="absolute inset-0 pointer-events-none flex items-center justify-center z-30"
> >
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
className="w-8 h-8 opacity-30 text-red-600" className="w-12 h-12 sm:w-14 sm:h-14 opacity-30 text-red-600"
fill="currentColor" fill="currentColor"
> >
<path d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12l-4.9 4.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.9a1 1 0 0 0 1.41-1.41L13.41 12l4.9-4.89a1 1 0 0 0-.01-1.4Z" /> <path d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 1 0 5.7 7.11L10.59 12l-4.9 4.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.9a1 1 0 0 0 1.41-1.41L13.41 12l4.9-4.89a1 1 0 0 0-.01-1.4Z" />
@ -570,10 +763,14 @@ export default function MapVetoPanel({ match }: Props) {
<img <img
src={getTeamLogo(match.teamB?.logo)} src={getTeamLogo(match.teamB?.logo)}
alt={match.teamB?.name ?? 'Team B'} alt={match.teamB?.name ?? 'Team B'}
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900" className={[
"w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain",
"transition-opacity transition-transform duration-300 ease-out transform-gpu will-change-transform",
pickedByB ? "opacity-100 scale-100" : "opacity-0 scale-90 pointer-events-none"
].join(" ")}
/> />
) : ( ) : (
<div className="w-6 h-6" /> <div className="w-10 h-10" />
)} )}
</li> </li>
) )
@ -581,8 +778,24 @@ export default function MapVetoPanel({ match }: Props) {
</ul> </ul>
</main> </main>
{/* Rechts Team B */} {/* Rechte Spalte Team B */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56"> <div className="flex flex-col items-end gap-3">
{/* Teamkopf B */}
<div className="flex items-center gap-3">
<TeamPremierRankBadge players={teamBPlayersForRank} />
<div className="min-w-0 text-right">
<div className="font-bold text-lg truncate">{match.teamB?.name ?? 'Team B'}</div>
</div>
<img
src={getTeamLogo(match.teamB?.logo)}
alt={match.teamB?.name ?? 'Team B'}
className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain"
width={12}
height={12}
/>
</div>
{/* Spieler B */}
{playersB.map((p: MatchPlayer) => ( {playersB.map((p: MatchPlayer) => (
<MapVoteProfileCard <MapVoteProfileCard
key={p.user.steamId} key={p.user.steamId}
@ -591,6 +804,7 @@ export default function MapVetoPanel({ match }: Props) {
avatar={p.user.avatar} avatar={p.user.avatar}
rank={p.stats?.rankNew ?? 0} rank={p.stats?.rankNew ?? 0}
matchType={match.matchType} matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={ isLeader={
(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === (state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) ===
p.user.steamId p.user.steamId
@ -602,7 +816,7 @@ export default function MapVetoPanel({ match }: Props) {
} }
/> />
))} ))}
</aside> </div>
</div> </div>
)} )}
</> </>

View File

@ -5,22 +5,21 @@ import PremierRankBadge from './PremierRankBadge'
type Side = 'A' | 'B' type Side = 'A' | 'B'
type Props = { type Props = {
side: Side // 'A' = linke Spalte, 'B' = rechte Spalte side: Side
name: string name: string
avatar?: string | null avatar?: string | null
rank?: number // Zahl aus deinen Stats rank?: number
matchType?: 'premier' | 'competitive' | string matchType?: 'premier' | 'competitive' | string
isLeader?: boolean isLeader?: boolean
isActiveTurn?: boolean // pulsiert, wenn dieses Team am Zug ist isActiveTurn?: boolean
onClick?: () => void // optional onClick?: () => void
} }
export default function MapVetoProfileCard({ export default function MapVoteProfileCard({
side, side,
name, name,
avatar, avatar,
rank = 0, rank = 0,
matchType = 'premier',
isLeader = false, isLeader = false,
isActiveTurn = false, isActiveTurn = false,
onClick, onClick,
@ -32,20 +31,24 @@ export default function MapVetoProfileCard({
type="button" type="button"
onClick={onClick} onClick={onClick}
className={[ className={[
'group relative w-full', 'group relative w-full rounded-xl shadow-md',
isRight ? 'ml-auto text-right' : 'mr-auto text-left', isRight ? 'ml-auto text-right' : 'mr-auto text-left',
].join(' ')} ]
.filter(Boolean)
.join(' ')}
title={isLeader ? `${name} (Leader)` : name} title={isLeader ? `${name} (Leader)` : name}
> >
<div <div
className={[ className={[
'flex items-center gap-3 rounded-xl border bg-white/90 dark:bg-neutral-800/90', 'relative flex items-center gap-3 rounded-xl border dark:border-neutral-700 shadow-sm px-3 py-2',
'dark:border-neutral-700 shadow-sm px-3 py-2 transition', 'transition-colors duration-300 ease-in-out justify-between',
isActiveTurn isActiveTurn
? 'ring-2 ring-blue-500/30 shadow-md' ? 'bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-800/30'
: 'ring-1 ring-black/5 hover:ring-black/10', : 'bg-white/90 dark:bg-neutral-800/90 hover:bg-neutral-200/10',
isRight ? 'flex-row-reverse' : 'flex-row', isRight ? 'flex-row-reverse' : 'flex-row',
].join(' ')} ]
.filter(Boolean)
.join(' ')}
> >
{/* Avatar */} {/* Avatar */}
<div className="relative shrink-0"> <div className="relative shrink-0">
@ -64,24 +67,29 @@ export default function MapVetoProfileCard({
].join(' ')} ].join(' ')}
title="Team-Leader" title="Team-Leader"
> >
{/* Stern-Icon */} <svg
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="currentColor" aria-hidden> viewBox="0 0 24 24"
<path d="m12 17.27 6.18 3.73-1.64-7.03L21 9.24l-7.19-.62L12 2 10.19 8.62 3 9.24l4.46 4.73L5.82 21z"/> className="w-3.5 h-3.5"
fill="currentColor"
aria-hidden
>
<path d="m12 17.27 6.18 3.73-1.64-7.03L21 9.24l-7.19-.62L12 2 10.19 8.62 3 9.24l4.46 4.73L5.82 21z" />
</svg> </svg>
</span> </span>
)} )}
</div> </div>
{/* Text + Rank */} {/* Name + Status */}
<div className={['min-w-0', isRight ? 'items-end text-right' : 'items-start text-left', 'flex flex-col'].join(' ')}> <div
<div className="flex items-center gap-2 max-w-[160px]"> className={[
<span className="truncate font-medium text-gray-900 dark:text-neutral-100"> 'min-w-0 flex-1',
{name} isRight ? 'items-end text-right' : 'items-start text-left',
</span> 'flex flex-col',
<span className="opacity-90"> ].join(' ')}
<PremierRankBadge rank={rank ?? 0} /> >
</span> <span className="truncate font-medium text-gray-900 dark:text-neutral-100">
</div> {name}
</span>
{isActiveTurn ? ( {isActiveTurn ? (
<span className="mt-0.5 text-[11px] font-medium text-blue-700 dark:text-blue-300"> <span className="mt-0.5 text-[11px] font-medium text-blue-700 dark:text-blue-300">
am Zug am Zug
@ -92,6 +100,9 @@ export default function MapVetoProfileCard({
</span> </span>
)} )}
</div> </div>
{/* PremierRank ganz außen */}
<PremierRankBadge rank={rank ?? 0} />
</div> </div>
</button> </button>
) )

View File

@ -22,7 +22,7 @@ import type { EditSide } from './EditMatchPlayersModal'
import type { Match, MatchPlayer } from '../types/match' import type { Match, MatchPlayer } from '../types/match'
import Button from './Button' import Button from './Button'
import { MAP_OPTIONS } from '../lib/mapOptions' import { MAP_OPTIONS } from '../lib/mapOptions'
import MapVetoBanner from './MapVetoBanner' import MapVoteBanner from './MapVoteBanner'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import { Team } from '../types/team' import { Team } from '../types/team'
import Alert from './Alert' import Alert from './Alert'
@ -71,7 +71,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
const mapKey = normalizeMapKey(match.map) const mapKey = normalizeMapKey(match.map)
const mapLabel = const mapLabel =
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ?? MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapveto')?.label ?? MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ??
'Unbekannte Map' 'Unbekannte Map'
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */ /* ─── Match-Zeitpunkt ─────────────────────────────────────── */
@ -81,24 +81,24 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */ /* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
const [editSide, setEditSide] = useState<EditSide | null>(null) const [editSide, setEditSide] = useState<EditSide | null>(null)
/* ─── Live-Uhr (für Veto-Zeitpunkt) ───────────────────────── */ /* ─── Live-Uhr (für vote-Zeitpunkt) ───────────────────────── */
useEffect(() => { useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000) const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
const vetoOpensAtTs = useMemo(() => { const voteOpensAtTs = useMemo(() => {
const base = match.mapVeto?.opensAt const base = match.mapVote?.opensAt
? new Date(match.mapVeto.opensAt).getTime() ? new Date(match.mapVote.opensAt).getTime()
: new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() - 60 * 60 * 1000 : new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() - 60 * 60 * 1000
return base return base
}, [match.mapVeto?.opensAt, match.matchDate, match.demoDate, initialNow]) }, [match.mapVote?.opensAt, match.matchDate, match.demoDate, initialNow])
const endDate = new Date(vetoOpensAtTs) const endDate = new Date(voteOpensAtTs)
const mapVetoStarted = (match.mapVeto?.isOpen ?? false) || now >= vetoOpensAtTs const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs
const showEditA = canEditA && !mapVetoStarted const showEditA = canEditA && !mapvoteStarted
const showEditB = canEditB && !mapVetoStarted const showEditB = canEditB && !mapvoteStarted
/* ─── SSE-Listener ─────────────────────────────────────────── */ /* ─── SSE-Listener ─────────────────────────────────────────── */
useEffect(() => { useEffect(() => {
@ -271,7 +271,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0} <strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div> </div>
<MapVetoBanner match={match} initialNow={initialNow} /> <MapVoteBanner match={match} initialNow={initialNow} />
{/* ───────── Team-Blöcke ───────── */} {/* ───────── Team-Blöcke ───────── */}
<div className="border-t pt-4 mt-4 space-y-10"> <div className="border-t pt-4 mt-4 space-y-10">
@ -369,7 +369,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
defaultTeamBName={match.teamB?.name ?? null} defaultTeamBName={match.teamB?.name ?? null}
defaultDateISO={match.matchDate ?? match.demoDate ?? null} defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null} defaultMap={match.map ?? null}
defaultVetoLeadMinutes={60} defaultVoteLeadMinutes={60}
onSaved={() => { router.refresh() }} onSaved={() => { router.refresh() }}
/> />
)} )}

View File

@ -25,7 +25,7 @@ export default function TeamCard({
const [joining, setJoining] = useState(false) const [joining, setJoining] = useState(false)
const isRequested = Boolean(invitationId) const isRequested = Boolean(invitationId)
const isDisabled = joining || currentUserSteamId === team.leader const isDisabled = joining || currentUserSteamId === team.leader?.steamId
const handleClick = async () => { const handleClick = async () => {
if (joining) return if (joining) return

View File

@ -200,7 +200,7 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
{matches.map(m => { {matches.map(m => {
const mapInfo = const mapInfo =
MAP_OPTIONS.find(opt => opt.key === m.map) ?? MAP_OPTIONS.find(opt => opt.key === m.map) ??
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapveto') MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')
const [scoreCT, scoreT] = parseScore(m.score) const [scoreCT, scoreT] = parseScore(m.score)
const ownCTSide = m.team !== 'T' const ownCTSide = m.team !== 'T'

View File

@ -1,25 +1,73 @@
// src/lib/mapOptions.ts // src/lib/mapOptions.ts
export type MapOption = { key: string; label: string } export type MapOption = {
key: string
label: string
images: string[]
}
export const MAP_OPTIONS: MapOption[] = [ export const MAP_OPTIONS: MapOption[] = [
{ key: 'de_train', label: 'Train' }, {
{ key: 'ar_baggage', label: 'Baggage' }, key: 'de_train',
{ key: 'ar_pool_day', label: 'Pool Day' }, label: 'Train',
{ key: 'ar_shoots', label: 'Shoots' }, images: [
{ key: 'cs_agency', label: 'Agency' }, '/assets/img/maps/de_train/1.jpg',
{ key: 'cs_italy', label: 'Italy' }, '/assets/img/maps/de_train/2.jpg',
{ key: 'cs_office', label: 'Office' }, ],
{ key: 'de_ancient', label: 'Ancient' }, },
{ key: 'de_anubis', label: 'Anubis' }, {
{ key: 'de_brewery', label: 'Brewery' }, key: 'de_dust2',
{ key: 'de_dogtown', label: 'Dogtown' }, label: 'Dust 2',
{ key: 'de_dust2', label: 'Dust 2' }, images: [
{ key: 'de_grail', label: 'Grail' }, '/assets/img/maps/de_dust2/1.jpg',
{ key: 'de_inferno', label: 'Inferno' }, '/assets/img/maps/de_dust2/2.jpg',
{ key: 'de_jura', label: 'Jura' }, ],
{ key: 'de_mirage', label: 'Mirage' }, },
{ key: 'de_nuke', label: 'Nuke' }, {
{ key: 'de_overpass', label: 'Overpass' }, key: 'de_mirage',
{ key: 'de_vertigo', label: 'Vertigo' }, label: 'Mirage',
{ key: 'lobby_mapveto', label: 'Pick/Ban' }, images: [
'/assets/img/maps/de_mirage/1.jpg',
'/assets/img/maps/de_mirage/2.jpg',
],
},
{
key: 'de_nuke',
label: 'Nuke',
images: [
'/assets/img/maps/de_nuke/1.jpg',
'/assets/img/maps/de_nuke/2.jpg',
],
},
{
key: 'de_ancient',
label: 'Ancient',
images: [
'/assets/img/maps/de_ancient/1.jpg',
'/assets/img/maps/de_ancient/2.jpg',
],
},
{
key: 'de_inferno',
label: 'Inferno',
images: [
'/assets/img/maps/de_inferno/1.jpg',
'/assets/img/maps/de_inferno/2.jpg',
],
},
{
key: 'de_overpass',
label: 'Overpass',
images: [
'/assets/img/maps/de_overpass/1.jpg',
'/assets/img/maps/de_overpass/2.jpg',
],
},
{
key: 'lobby_mapvote',
label: 'Pick/Ban',
images: [
'/assets/img/maps/lobby_mapvote/1.jpg',
'/assets/img/maps/lobby_mapvote/2.jpg',
],
},
] ]

View File

@ -20,12 +20,11 @@ export const SSE_EVENT_TYPES = [
'expired-sharecode', 'expired-sharecode',
'team-invite-revoked', 'team-invite-revoked',
'map-vote-updated', 'map-vote-updated',
'map-vote-admin-edit',
'match-created', 'match-created',
'matches-updated', 'matches-updated',
'match-deleted', 'match-deleted',
'match-updated', 'match-updated',
// neu: gezieltes Event, wenn sich die Aufstellung ändert
'match-lineup-updated', 'match-lineup-updated',
] as const; ] as const;
@ -67,6 +66,8 @@ export const MATCH_EVENTS: ReadonlySet<SSEEventType> = new Set([
'match-deleted', 'match-deleted',
'match-lineup-updated', 'match-lineup-updated',
'match-updated', 'match-updated',
'map-vote-updated',
'map-vote-admin-edit',
]); ]);
// Event-Typen, die das NotificationCenter betreffen // Event-Typen, die das NotificationCenter betreffen

View File

@ -1,10 +1,10 @@
// app/match-details/[matchId]/vote/VoteClient.tsx // app/match-details/[matchId]/vote/VoteClient.tsx
'use client' 'use client'
import MapVetoPanel from '@/app/components/MapVetoPanel' import MapVotePanel from '@/app/components/MapVotePanel'
import { useMatch } from '../MatchContext' // aus dem Layout-Context import { useMatch } from '../MatchContext' // aus dem Layout-Context
export default function VoteClient() { export default function VoteClient() {
const match = useMatch() const match = useMatch()
return <MapVetoPanel match={match} /> return <MapVotePanel match={match} />
} }

View File

@ -1,32 +0,0 @@
// /types/mapveto.ts
import type { Player } from './team'
export type MapVetoStep = {
order: number
action: 'ban'|'pick'|'decider'
teamId: string | null
map: string | null
chosenAt: string | null
chosenBy: string | null
}
export type MapVetoTeam = {
id: string | null
name?: string | null
logo?: string | null
leader: Player | null
players: Player[]
}
export type MapVetoState = {
bestOf: number
mapPool: string[]
currentIndex: number
locked: boolean
opensAt: string | null
steps: MapVetoStep[]
teams?: {
teamA: MapVetoTeam
teamB: MapVetoTeam
}
}

50
src/app/types/mapvote.ts Normal file
View File

@ -0,0 +1,50 @@
// /types/mapvote.ts
import type { Player } from './team'
export type MapVoteStep = {
order: number
action: 'ban'|'pick'|'decider'
teamId: string | null
map: string | null
chosenAt: string | null
chosenBy: string | null
}
export type MapVoteTeam = {
id: string | null
name?: string | null
logo?: string | null
leader: Player | null
players: Player[]
}
export type MapVoteMapVisual = {
/** Menschlicher Name aus MAP_OPTIONS */
label: string
/** Vom Server vorab ausgewähltes Hintergrundbild für diese Map (stabil pro Match) */
bg: string
/** Optional: alle verfügbaren Bilder (falls du sie im FE brauchst) */
images?: string[]
}
// ⬅️ NEU: Admin-Edit-Metadaten (für globalen Freeze)
export type MapVoteAdminEdit = {
enabled: boolean
by: string | null // steamId des Admins
since: string | null // ISO-String
}
export type MapVoteState = {
bestOf: number
mapPool: string[]
mapVisuals: Record<string, MapVoteMapVisual>
currentIndex: number
locked: boolean
opensAt: string | null
steps: MapVoteStep[]
teams?: {
teamA: MapVoteTeam
teamB: MapVoteTeam
}
adminEdit?: MapVoteAdminEdit // ⬅️ NEU
}

View File

@ -21,7 +21,7 @@ export type Match = {
teamA: Team teamA: Team
teamB: Team teamB: Team
mapVeto?: { mapVote?: {
status: 'not_started' | 'in_progress' | 'completed' | null status: 'not_started' | 'in_progress' | 'completed' | null
opensAt: string | null opensAt: string | null
isOpen: boolean | null isOpen: boolean | null

File diff suppressed because one or more lines are too long

View File

@ -20,12 +20,12 @@ exports.Prisma = Prisma
exports.$Enums = {} exports.$Enums = {}
/** /**
* Prisma Client JS version: 6.13.0 * Prisma Client JS version: 6.14.0
* Query Engine version: 361e86d0ea4987e9f53a565309b3eed797a6bcbd * Query Engine version: 717184b7b35ea05dfa71a3236b7af656013e1e49
*/ */
Prisma.prismaVersion = { Prisma.prismaVersion = {
client: "6.13.0", client: "6.14.0",
engine: "361e86d0ea4987e9f53a565309b3eed797a6bcbd" engine: "717184b7b35ea05dfa71a3236b7af656013e1e49"
} }
Prisma.PrismaClientKnownRequestError = () => { Prisma.PrismaClientKnownRequestError = () => {
@ -276,7 +276,7 @@ exports.Prisma.ServerRequestScalarFieldEnum = {
createdAt: 'createdAt' createdAt: 'createdAt'
}; };
exports.Prisma.MapVetoScalarFieldEnum = { exports.Prisma.MapVoteScalarFieldEnum = {
id: 'id', id: 'id',
matchId: 'matchId', matchId: 'matchId',
bestOf: 'bestOf', bestOf: 'bestOf',
@ -284,13 +284,15 @@ exports.Prisma.MapVetoScalarFieldEnum = {
currentIdx: 'currentIdx', currentIdx: 'currentIdx',
locked: 'locked', locked: 'locked',
opensAt: 'opensAt', opensAt: 'opensAt',
adminEditingBy: 'adminEditingBy',
adminEditingSince: 'adminEditingSince',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
exports.Prisma.MapVetoStepScalarFieldEnum = { exports.Prisma.MapVoteStepScalarFieldEnum = {
id: 'id', id: 'id',
vetoId: 'vetoId', voteId: 'voteId',
order: 'order', order: 'order',
action: 'action', action: 'action',
teamId: 'teamId', teamId: 'teamId',
@ -332,7 +334,7 @@ exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
COMPLETED: 'COMPLETED' COMPLETED: 'COMPLETED'
}; };
exports.MapVetoAction = exports.$Enums.MapVetoAction = { exports.MapVoteAction = exports.$Enums.MapVoteAction = {
BAN: 'BAN', BAN: 'BAN',
PICK: 'PICK', PICK: 'PICK',
DECIDER: 'DECIDER' DECIDER: 'DECIDER'
@ -350,8 +352,8 @@ exports.Prisma.ModelName = {
Schedule: 'Schedule', Schedule: 'Schedule',
DemoFile: 'DemoFile', DemoFile: 'DemoFile',
ServerRequest: 'ServerRequest', ServerRequest: 'ServerRequest',
MapVeto: 'MapVeto', MapVote: 'MapVote',
MapVetoStep: 'MapVetoStep' MapVoteStep: 'MapVoteStep'
}; };
/** /**

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"name": "prisma-client-c63ea7016e1a1ac5fd312c9d5648426292d519ae426c4dfab5e695d19cc61ccb", "name": "prisma-client-d6ee9e0758cbf84308223ad2add52d1ebc187831f60d22da54a3cc72e384b3c6",
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"browser": "index-browser.js", "browser": "index-browser.js",
@ -145,6 +145,6 @@
}, },
"./*": "./*" "./*": "./*"
}, },
"version": "6.13.0", "version": "6.14.0",
"sideEffects": false "sideEffects": false
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -209,7 +209,7 @@ declare const ColumnTypeEnum: {
declare type CompactedBatchResponse = { declare type CompactedBatchResponse = {
type: 'compacted'; type: 'compacted';
plan: {}; plan: QueryPlanNode;
arguments: Record<string, {}>[]; arguments: Record<string, {}>[];
nestedSelection: string[]; nestedSelection: string[];
keys: string[]; keys: string[];
@ -376,6 +376,19 @@ declare type DatamodelEnum = ReadonlyDeep_2<{
declare function datamodelEnumToSchemaEnum(datamodelEnum: DatamodelEnum): SchemaEnum; declare function datamodelEnumToSchemaEnum(datamodelEnum: DatamodelEnum): SchemaEnum;
declare type DataRule = {
type: 'rowCountEq';
args: number;
} | {
type: 'rowCountNeq';
args: number;
} | {
type: 'affectedRowCountEq';
args: number;
} | {
type: 'never';
};
declare type Datasource = { declare type Datasource = {
url?: string; url?: string;
}; };
@ -709,7 +722,7 @@ export declare function defineDmmfProperty(target: object, runtimeDataModel: Run
declare function defineExtension(ext: ExtensionArgs | ((client: Client) => Client)): (client: Client) => Client; declare function defineExtension(ext: ExtensionArgs | ((client: Client) => Client)): (client: Client) => Client;
declare const denylist: readonly ["$connect", "$disconnect", "$on", "$transaction", "$use", "$extends"]; declare const denylist: readonly ["$connect", "$disconnect", "$on", "$transaction", "$extends"];
declare type Deprecation = ReadonlyDeep_2<{ declare type Deprecation = ReadonlyDeep_2<{
sinceVersion: string; sinceVersion: string;
@ -1140,114 +1153,9 @@ declare interface EnvValue {
export declare type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? 1 : 0; export declare type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? 1 : 0;
declare type Error_2 = { declare type Error_2 = MappedError & {
kind: 'GenericJs'; originalCode?: string;
id: number; originalMessage?: string;
} | {
kind: 'UnsupportedNativeDataType';
type: string;
} | {
kind: 'InvalidIsolationLevel';
level: string;
} | {
kind: 'LengthMismatch';
column?: string;
} | {
kind: 'UniqueConstraintViolation';
constraint?: {
fields: string[];
} | {
index: string;
} | {
foreignKey: {};
};
} | {
kind: 'NullConstraintViolation';
constraint?: {
fields: string[];
} | {
index: string;
} | {
foreignKey: {};
};
} | {
kind: 'ForeignKeyConstraintViolation';
constraint?: {
fields: string[];
} | {
index: string;
} | {
foreignKey: {};
};
} | {
kind: 'DatabaseNotReachable';
host?: string;
port?: number;
} | {
kind: 'DatabaseDoesNotExist';
db?: string;
} | {
kind: 'DatabaseAlreadyExists';
db?: string;
} | {
kind: 'DatabaseAccessDenied';
db?: string;
} | {
kind: 'ConnectionClosed';
} | {
kind: 'TlsConnectionError';
reason: string;
} | {
kind: 'AuthenticationFailed';
user?: string;
} | {
kind: 'TransactionWriteConflict';
} | {
kind: 'TableDoesNotExist';
table?: string;
} | {
kind: 'ColumnNotFound';
column?: string;
} | {
kind: 'TooManyConnections';
cause: string;
} | {
kind: 'ValueOutOfRange';
cause: string;
} | {
kind: 'MissingFullTextSearchIndex';
} | {
kind: 'SocketTimeout';
} | {
kind: 'InconsistentColumnData';
cause: string;
} | {
kind: 'TransactionAlreadyClosed';
cause: string;
} | {
kind: 'postgres';
code: string;
severity: string;
message: string;
detail: string | undefined;
column: string | undefined;
hint: string | undefined;
} | {
kind: 'mysql';
code: number;
message: string;
state: string;
} | {
kind: 'sqlite';
/**
* Sqlite extended error code: https://www.sqlite.org/rescode.html
*/
extendedCode: number;
message: string;
} | {
kind: 'mssql';
code: number;
message: string;
}; };
declare type ErrorCapturingFunction<T> = T extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Result_4<ErrorCapturingInterface<R>>> : T extends (...args: infer A) => infer R ? (...args: A) => Result_4<ErrorCapturingInterface<R>> : T; declare type ErrorCapturingFunction<T> = T extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Result_4<ErrorCapturingInterface<R>>> : T extends (...args: infer A) => infer R ? (...args: A) => Result_4<ErrorCapturingInterface<R>> : T;
@ -1314,7 +1222,6 @@ declare type ExtendedSpanOptions = SpanOptions & {
/** The name of the span */ /** The name of the span */
name: string; name: string;
internal?: boolean; internal?: boolean;
middleware?: boolean;
/** Whether it propagates context (?=true) */ /** Whether it propagates context (?=true) */
active?: boolean; active?: boolean;
/** The context to append the span to */ /** The context to append the span to */
@ -1452,12 +1359,36 @@ declare type FieldDefault = ReadonlyDeep_2<{
declare type FieldDefaultScalar = string | boolean | number; declare type FieldDefaultScalar = string | boolean | number;
declare type FieldInitializer = {
type: 'value';
value: PrismaValue;
} | {
type: 'lastInsertId';
};
declare type FieldKind = 'scalar' | 'object' | 'enum' | 'unsupported'; declare type FieldKind = 'scalar' | 'object' | 'enum' | 'unsupported';
declare type FieldLocation = 'scalar' | 'inputObjectTypes' | 'outputObjectTypes' | 'enumTypes' | 'fieldRefTypes'; declare type FieldLocation = 'scalar' | 'inputObjectTypes' | 'outputObjectTypes' | 'enumTypes' | 'fieldRefTypes';
declare type FieldNamespace = 'model' | 'prisma'; declare type FieldNamespace = 'model' | 'prisma';
declare type FieldOperation = {
type: 'set';
value: PrismaValue;
} | {
type: 'add';
value: PrismaValue;
} | {
type: 'subtract';
value: PrismaValue;
} | {
type: 'multiply';
value: PrismaValue;
} | {
type: 'divide';
value: PrismaValue;
};
/** /**
* A reference to a specific field of a specific model * A reference to a specific field of a specific model
*/ */
@ -1483,6 +1414,21 @@ export declare interface Fn<Params = unknown, Returns = unknown> {
returns: Returns; returns: Returns;
} }
declare type Fragment = {
type: 'stringChunk';
chunk: string;
} | {
type: 'parameter';
} | {
type: 'parameterTuple';
} | {
type: 'parameterTupleList';
itemPrefix: string;
itemSeparator: string;
itemSuffix: string;
groupSeparator: string;
};
declare interface GeneratorConfig { declare interface GeneratorConfig {
name: string; name: string;
output: EnvValue | null; output: EnvValue | null;
@ -1576,7 +1522,6 @@ export declare function getPrismaClient(config: GetPrismaClientConfig): {
_clientVersion: string; _clientVersion: string;
_errorFormat: ErrorFormat; _errorFormat: ErrorFormat;
_tracingHelper: TracingHelper; _tracingHelper: TracingHelper;
_middlewares: MiddlewareHandler<QueryMiddleware>;
_previewFeatures: string[]; _previewFeatures: string[];
_activeProvider: string; _activeProvider: string;
_globalOmit?: GlobalOmitOptions | undefined; _globalOmit?: GlobalOmitOptions | undefined;
@ -1591,11 +1536,6 @@ export declare function getPrismaClient(config: GetPrismaClientConfig): {
*/ */
_appliedParent: any; _appliedParent: any;
_createPrismaPromise: PrismaPromiseFactory; _createPrismaPromise: PrismaPromiseFactory;
/**
* Hook a middleware into the client
* @param middleware to hook
*/
$use(middleware: QueryMiddleware): void;
$on<E extends ExtendedEventType>(eventType: E, callback: EventCallback<E>): any; $on<E extends ExtendedEventType>(eventType: E, callback: EventCallback<E>): any;
$connect(): Promise<void>; $connect(): Promise<void>;
/** /**
@ -1875,6 +1815,14 @@ declare type IndexField = ReadonlyDeep_2<{
declare type IndexType = 'id' | 'normal' | 'unique' | 'fulltext'; declare type IndexType = 'id' | 'normal' | 'unique' | 'fulltext';
declare type InMemoryOps = {
pagination: Pagination | null;
distinct: string[] | null;
reverse: boolean;
linkingFields: string[] | null;
nested: Record<string, InMemoryOps>;
};
/** /**
* Matches a JSON array. * Matches a JSON array.
* Unlike \`JsonArray\`, readonly arrays are assignable to this type. * Unlike \`JsonArray\`, readonly arrays are assignable to this type.
@ -2021,6 +1969,13 @@ declare interface Job {
*/ */
export declare function join(values: readonly RawValue[], separator?: string, prefix?: string, suffix?: string): Sql; export declare function join(values: readonly RawValue[], separator?: string, prefix?: string, suffix?: string): Sql;
declare type JoinExpression = {
child: QueryPlanNode;
on: [left: string, right: string][];
parentField: string;
isRelationUnique: boolean;
};
export declare type JsArgs = { export declare type JsArgs = {
select?: Selection_2; select?: Selection_2;
include?: Selection_2; include?: Selection_2;
@ -2190,6 +2145,116 @@ export declare function makeStrictEnum<T extends Record<PropertyKey, string | nu
export declare function makeTypedQueryFactory(sql: string): (...values: any[]) => TypedSql<any[], unknown>; export declare function makeTypedQueryFactory(sql: string): (...values: any[]) => TypedSql<any[], unknown>;
declare type MappedError = {
kind: 'GenericJs';
id: number;
} | {
kind: 'UnsupportedNativeDataType';
type: string;
} | {
kind: 'InvalidIsolationLevel';
level: string;
} | {
kind: 'LengthMismatch';
column?: string;
} | {
kind: 'UniqueConstraintViolation';
constraint?: {
fields: string[];
} | {
index: string;
} | {
foreignKey: {};
};
} | {
kind: 'NullConstraintViolation';
constraint?: {
fields: string[];
} | {
index: string;
} | {
foreignKey: {};
};
} | {
kind: 'ForeignKeyConstraintViolation';
constraint?: {
fields: string[];
} | {
index: string;
} | {
foreignKey: {};
};
} | {
kind: 'DatabaseNotReachable';
host?: string;
port?: number;
} | {
kind: 'DatabaseDoesNotExist';
db?: string;
} | {
kind: 'DatabaseAlreadyExists';
db?: string;
} | {
kind: 'DatabaseAccessDenied';
db?: string;
} | {
kind: 'ConnectionClosed';
} | {
kind: 'TlsConnectionError';
reason: string;
} | {
kind: 'AuthenticationFailed';
user?: string;
} | {
kind: 'TransactionWriteConflict';
} | {
kind: 'TableDoesNotExist';
table?: string;
} | {
kind: 'ColumnNotFound';
column?: string;
} | {
kind: 'TooManyConnections';
cause: string;
} | {
kind: 'ValueOutOfRange';
cause: string;
} | {
kind: 'MissingFullTextSearchIndex';
} | {
kind: 'SocketTimeout';
} | {
kind: 'InconsistentColumnData';
cause: string;
} | {
kind: 'TransactionAlreadyClosed';
cause: string;
} | {
kind: 'postgres';
code: string;
severity: string;
message: string;
detail: string | undefined;
column: string | undefined;
hint: string | undefined;
} | {
kind: 'mysql';
code: number;
message: string;
state: string;
} | {
kind: 'sqlite';
/**
* Sqlite extended error code: https://www.sqlite.org/rescode.html
*/
extendedCode: number;
message: string;
} | {
kind: 'mssql';
code: number;
message: string;
};
declare type Mappings = ReadonlyDeep_2<{ declare type Mappings = ReadonlyDeep_2<{
modelOperations: ModelMapping[]; modelOperations: ModelMapping[];
otherOperations: { otherOperations: {
@ -2289,14 +2354,6 @@ declare type MiddlewareArgsMapper<RequestArgs, MiddlewareArgs> = {
middlewareArgsToRequestArgs(middlewareArgs: MiddlewareArgs): RequestArgs; middlewareArgsToRequestArgs(middlewareArgs: MiddlewareArgs): RequestArgs;
}; };
declare class MiddlewareHandler<M extends Function> {
private _middlewares;
use(middleware: M): void;
get(id: number): M | undefined;
has(id: number): boolean;
length(): number;
}
declare type Model = ReadonlyDeep_2<{ declare type Model = ReadonlyDeep_2<{
name: string; name: string;
dbName: string | null; dbName: string | null;
@ -2378,7 +2435,7 @@ export declare type ModelQueryOptionsCbArgs = {
declare type MultiBatchResponse = { declare type MultiBatchResponse = {
type: 'multi'; type: 'multi';
plans: object[]; plans: QueryPlanNode[];
}; };
export declare type NameArgs = { export declare type NameArgs = {
@ -2496,6 +2553,12 @@ declare type OutputType = ReadonlyDeep_2<{
declare type OutputTypeRef = TypeRef<'scalar' | 'outputObjectTypes' | 'enumTypes'>; declare type OutputTypeRef = TypeRef<'scalar' | 'outputObjectTypes' | 'enumTypes'>;
declare type Pagination = {
cursor: Record<string, PrismaValue> | null;
take: number | null;
skip: number | null;
};
export declare function Param<$Type, $Value extends string>(name: $Value): Param<$Type, $Value>; export declare function Param<$Type, $Value extends string>(name: $Value): Param<$Type, $Value>;
export declare type Param<out $Type, $Value extends string> = { export declare type Param<out $Type, $Value extends string> = {
@ -2523,6 +2586,11 @@ declare type Pick_2<T, K extends string | number | symbol> = {
}; };
export { Pick_2 as Pick } export { Pick_2 as Pick }
declare interface PlaceholderFormat {
prefix: string;
hasNumbering: boolean;
}
declare type PrimaryKey = ReadonlyDeep_2<{ declare type PrimaryKey = ReadonlyDeep_2<{
name: string | null; name: string | null;
fields: string[]; fields: string[];
@ -2696,6 +2764,66 @@ declare type PrismaPromiseInteractiveTransaction<PayloadType = unknown> = {
declare type PrismaPromiseTransaction<PayloadType = unknown> = PrismaPromiseBatchTransaction | PrismaPromiseInteractiveTransaction<PayloadType>; declare type PrismaPromiseTransaction<PayloadType = unknown> = PrismaPromiseBatchTransaction | PrismaPromiseInteractiveTransaction<PayloadType>;
declare type PrismaValue = string | boolean | number | PrismaValue[] | null | Record<string, unknown> | PrismaValuePlaceholder | PrismaValueGenerator | PrismaValueBytes | PrismaValueBigInt;
declare type PrismaValueBigInt = {
prisma__type: 'bigint';
prisma__value: string;
};
declare type PrismaValueBytes = {
prisma__type: 'bytes';
prisma__value: string;
};
declare type PrismaValueGenerator = {
prisma__type: 'generatorCall';
prisma__value: {
name: string;
args: PrismaValue[];
};
};
declare type PrismaValuePlaceholder = {
prisma__type: 'param';
prisma__value: {
name: string;
type: string;
};
};
declare type PrismaValueType = {
type: 'Any';
} | {
type: 'String';
} | {
type: 'Int';
} | {
type: 'BigInt';
} | {
type: 'Float';
} | {
type: 'Boolean';
} | {
type: 'Decimal';
} | {
type: 'Date';
} | {
type: 'Time';
} | {
type: 'Array';
inner: PrismaValueType;
} | {
type: 'Json';
} | {
type: 'Object';
} | {
type: 'Bytes';
} | {
type: 'Enum';
inner: string;
};
export declare const PrivateResultType: unique symbol; export declare const PrivateResultType: unique symbol;
declare type Provider = 'mysql' | 'postgres' | 'sqlite' | 'sqlserver'; declare type Provider = 'mysql' | 'postgres' | 'sqlite' | 'sqlserver';
@ -2822,8 +2950,6 @@ declare type QueryEventType = 'query';
declare type QueryIntrospectionBuiltinType = 'int' | 'bigint' | 'float' | 'double' | 'string' | 'enum' | 'bytes' | 'bool' | 'char' | 'decimal' | 'json' | 'xml' | 'uuid' | 'datetime' | 'date' | 'time' | 'int-array' | 'bigint-array' | 'float-array' | 'double-array' | 'string-array' | 'char-array' | 'bytes-array' | 'bool-array' | 'decimal-array' | 'json-array' | 'xml-array' | 'uuid-array' | 'datetime-array' | 'date-array' | 'time-array' | 'null' | 'unknown'; declare type QueryIntrospectionBuiltinType = 'int' | 'bigint' | 'float' | 'double' | 'string' | 'enum' | 'bytes' | 'bool' | 'char' | 'decimal' | 'json' | 'xml' | 'uuid' | 'datetime' | 'date' | 'time' | 'int-array' | 'bigint-array' | 'float-array' | 'double-array' | 'string-array' | 'char-array' | 'bytes-array' | 'bool-array' | 'decimal-array' | 'json-array' | 'xml-array' | 'uuid-array' | 'datetime-array' | 'date-array' | 'time-array' | 'null' | 'unknown';
declare type QueryMiddleware = (params: QueryMiddlewareParams, next: (params: QueryMiddlewareParams) => Promise<unknown>) => Promise<unknown>;
declare type QueryMiddlewareParams = { declare type QueryMiddlewareParams = {
/** The model this is executed on */ /** The model this is executed on */
model?: string; model?: string;
@ -2859,6 +2985,130 @@ declare type QueryOutput = ReadonlyDeep_2<{
isList: boolean; isList: boolean;
}>; }>;
declare type QueryPlanBinding = {
name: string;
expr: QueryPlanNode;
};
declare type QueryPlanDbQuery = {
type: 'rawSql';
sql: string;
params: PrismaValue[];
} | {
type: 'templateSql';
fragments: Fragment[];
placeholderFormat: PlaceholderFormat;
params: PrismaValue[];
chunkable: boolean;
};
declare type QueryPlanNode = {
type: 'value';
args: PrismaValue;
} | {
type: 'seq';
args: QueryPlanNode[];
} | {
type: 'get';
args: {
name: string;
};
} | {
type: 'let';
args: {
bindings: QueryPlanBinding[];
expr: QueryPlanNode;
};
} | {
type: 'getFirstNonEmpty';
args: {
names: string[];
};
} | {
type: 'query';
args: QueryPlanDbQuery;
} | {
type: 'execute';
args: QueryPlanDbQuery;
} | {
type: 'reverse';
args: QueryPlanNode;
} | {
type: 'sum';
args: QueryPlanNode[];
} | {
type: 'concat';
args: QueryPlanNode[];
} | {
type: 'unique';
args: QueryPlanNode;
} | {
type: 'required';
args: QueryPlanNode;
} | {
type: 'join';
args: {
parent: QueryPlanNode;
children: JoinExpression[];
};
} | {
type: 'mapField';
args: {
field: string;
records: QueryPlanNode;
};
} | {
type: 'transaction';
args: QueryPlanNode;
} | {
type: 'dataMap';
args: {
expr: QueryPlanNode;
structure: ResultNode;
enums: Record<string, Record<string, string>>;
};
} | {
type: 'validate';
args: {
expr: QueryPlanNode;
rules: DataRule[];
} & ValidationError;
} | {
type: 'if';
args: {
value: QueryPlanNode;
rule: DataRule;
then: QueryPlanNode;
else: QueryPlanNode;
};
} | {
type: 'unit';
} | {
type: 'diff';
args: {
from: QueryPlanNode;
to: QueryPlanNode;
};
} | {
type: 'initializeRecord';
args: {
expr: QueryPlanNode;
fields: Record<string, FieldInitializer>;
};
} | {
type: 'mapRecord';
args: {
expr: QueryPlanNode;
fields: Record<string, FieldOperation>;
};
} | {
type: 'process';
args: {
expr: QueryPlanNode;
operations: InMemoryOps;
};
};
/** /**
* Create raw SQL statement. * Create raw SQL statement.
*/ */
@ -3047,6 +3297,19 @@ export declare type ResultFieldDefinition = {
compute: ResultArgsFieldCompute; compute: ResultArgsFieldCompute;
}; };
declare type ResultNode = {
type: 'AffectedRows';
} | {
type: 'Object';
fields: Record<string, ResultNode>;
serializedName: string | null;
skipNulls: boolean;
} | {
type: 'Value';
dbName: string;
resultType: PrismaValueType;
};
export declare type Return<T> = T extends (...args: any[]) => infer R ? R : T; export declare type Return<T> = T extends (...args: any[]) => infer R ? R : T;
export declare type RuntimeDataModel = { export declare type RuntimeDataModel = {
@ -3679,6 +3942,48 @@ declare namespace Utils {
} }
} }
declare type ValidationError = {
error_identifier: 'RELATION_VIOLATION';
context: {
relation: string;
modelA: string;
modelB: string;
};
} | {
error_identifier: 'MISSING_RELATED_RECORD';
context: {
model: string;
relation: string;
relationType: string;
operation: string;
neededFor?: string;
};
} | {
error_identifier: 'MISSING_RECORD';
context: {
operation: string;
};
} | {
error_identifier: 'INCOMPLETE_CONNECT_INPUT';
context: {
expectedRows: number;
};
} | {
error_identifier: 'INCOMPLETE_CONNECT_OUTPUT';
context: {
expectedRows: number;
relation: string;
relationType: string;
};
} | {
error_identifier: 'RECORDS_NOT_CONNECTED';
context: {
relation: string;
parent: string;
child: string;
};
};
declare function validator<V>(): <S>(select: Exact<S, V>) => S; declare function validator<V>(): <S>(select: Exact<S, V>) => S;
declare function validator<C, M extends Exclude<keyof C, `$${string}`>, O extends keyof C[M] & Operation>(client: C, model: M, operation: O): <S>(select: Exact<S, Args<C[M], O>>) => S; declare function validator<C, M extends Exclude<keyof C, `$${string}`>, O extends keyof C[M] & Operation>(client: C, model: M, operation: O): <S>(select: Exact<S, Args<C[M], O>>) => S;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -44,7 +44,7 @@ model User {
createdSchedules Schedule[] @relation("CreatedSchedules") createdSchedules Schedule[] @relation("CreatedSchedules")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules") confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVetoChoices MapVetoStep[] @relation("VetoStepChooser") mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
} }
model Team { model Team {
@ -68,7 +68,7 @@ model Team {
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA") schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB") schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVetoSteps MapVetoStep[] @relation("VetoStepTeam") mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
} }
model TeamInvite { model TeamInvite {
@ -138,7 +138,7 @@ model Match {
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate) matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVeto MapVeto? // 1:1 Map-Vote-Status mapVote MapVote?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -297,51 +297,49 @@ model ServerRequest {
// 🗺️ Map-Vote // 🗺️ Map-Vote
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
enum MapVetoAction { enum MapVoteAction {
BAN BAN
PICK PICK
DECIDER DECIDER
} }
model MapVeto { model MapVote {
id String @id @default(uuid()) id String @id @default(uuid())
matchId String @unique matchId String @unique
match Match @relation(fields: [matchId], references: [id]) match Match @relation(fields: [matchId], references: [id])
// Basiszustand bestOf Int @default(3)
bestOf Int @default(3) mapPool String[]
mapPool String[] // z.B. ["de_inferno","de_mirage",...] currentIdx Int @default(0)
currentIdx Int @default(0) locked Boolean @default(false)
locked Boolean @default(false) opensAt DateTime?
// Optional: serverseitig speichern, statt im UI zu berechnen adminEditingBy String?
opensAt DateTime? adminEditingSince DateTime?
steps MapVetoStep[] steps MapVoteStep[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model MapVetoStep { model MapVoteStep {
id String @id @default(uuid()) id String @id @default(uuid())
vetoId String voteId String
order Int order Int
action MapVetoAction action MapVoteAction
// Team, das am Zug ist (kann bei DECIDER null sein)
teamId String? teamId String?
team Team? @relation("VetoStepTeam", fields: [teamId], references: [id]) team Team? @relation("VoteStepTeam", fields: [teamId], references: [id])
// Ergebnis & wer gewählt hat
map String? map String?
chosenAt DateTime? chosenAt DateTime?
chosenBy String? chosenBy String?
chooser User? @relation("VetoStepChooser", fields: [chosenBy], references: [steamId]) chooser User? @relation("VoteStepChooser", fields: [chosenBy], references: [steamId])
veto MapVeto @relation(fields: [vetoId], references: [id]) vote MapVote @relation(fields: [voteId], references: [id])
@@unique([vetoId, order]) @@unique([voteId, order])
@@index([teamId]) @@index([teamId])
@@index([chosenBy]) @@index([chosenBy])
} }

View File

@ -20,12 +20,12 @@ exports.Prisma = Prisma
exports.$Enums = {} exports.$Enums = {}
/** /**
* Prisma Client JS version: 6.13.0 * Prisma Client JS version: 6.14.0
* Query Engine version: 361e86d0ea4987e9f53a565309b3eed797a6bcbd * Query Engine version: 717184b7b35ea05dfa71a3236b7af656013e1e49
*/ */
Prisma.prismaVersion = { Prisma.prismaVersion = {
client: "6.13.0", client: "6.14.0",
engine: "361e86d0ea4987e9f53a565309b3eed797a6bcbd" engine: "717184b7b35ea05dfa71a3236b7af656013e1e49"
} }
Prisma.PrismaClientKnownRequestError = () => { Prisma.PrismaClientKnownRequestError = () => {
@ -276,7 +276,7 @@ exports.Prisma.ServerRequestScalarFieldEnum = {
createdAt: 'createdAt' createdAt: 'createdAt'
}; };
exports.Prisma.MapVetoScalarFieldEnum = { exports.Prisma.MapVoteScalarFieldEnum = {
id: 'id', id: 'id',
matchId: 'matchId', matchId: 'matchId',
bestOf: 'bestOf', bestOf: 'bestOf',
@ -284,13 +284,15 @@ exports.Prisma.MapVetoScalarFieldEnum = {
currentIdx: 'currentIdx', currentIdx: 'currentIdx',
locked: 'locked', locked: 'locked',
opensAt: 'opensAt', opensAt: 'opensAt',
adminEditingBy: 'adminEditingBy',
adminEditingSince: 'adminEditingSince',
createdAt: 'createdAt', createdAt: 'createdAt',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
}; };
exports.Prisma.MapVetoStepScalarFieldEnum = { exports.Prisma.MapVoteStepScalarFieldEnum = {
id: 'id', id: 'id',
vetoId: 'vetoId', voteId: 'voteId',
order: 'order', order: 'order',
action: 'action', action: 'action',
teamId: 'teamId', teamId: 'teamId',
@ -332,7 +334,7 @@ exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
COMPLETED: 'COMPLETED' COMPLETED: 'COMPLETED'
}; };
exports.MapVetoAction = exports.$Enums.MapVetoAction = { exports.MapVoteAction = exports.$Enums.MapVoteAction = {
BAN: 'BAN', BAN: 'BAN',
PICK: 'PICK', PICK: 'PICK',
DECIDER: 'DECIDER' DECIDER: 'DECIDER'
@ -350,8 +352,8 @@ exports.Prisma.ModelName = {
Schedule: 'Schedule', Schedule: 'Schedule',
DemoFile: 'DemoFile', DemoFile: 'DemoFile',
ServerRequest: 'ServerRequest', ServerRequest: 'ServerRequest',
MapVeto: 'MapVeto', MapVote: 'MapVote',
MapVetoStep: 'MapVetoStep' MapVoteStep: 'MapVoteStep'
}; };
/** /**