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")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVetoChoices MapVetoStep[] @relation("VetoStepChooser")
mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
}
model Team {
@ -68,7 +68,7 @@ model Team {
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVetoSteps MapVetoStep[] @relation("VetoStepTeam")
mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
}
model TeamInvite {
@ -138,7 +138,7 @@ model Match {
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVeto MapVeto? // 1:1 Map-Vote-Status
mapVote MapVote?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -297,51 +297,50 @@ model ServerRequest {
// 🗺️ Map-Vote
// ──────────────────────────────────────────────
enum MapVetoAction {
enum MapVoteAction {
BAN
PICK
DECIDER
}
model MapVeto {
model MapVote {
id String @id @default(uuid())
matchId String @unique
match Match @relation(fields: [matchId], references: [id])
// Basiszustand
bestOf Int @default(3)
mapPool String[] // z.B. ["de_inferno","de_mirage",...]
mapPool String[]
currentIdx Int @default(0)
locked Boolean @default(false)
opensAt DateTime?
// Optional: serverseitig speichern, statt im UI zu berechnen
opensAt DateTime?
adminEditingBy String?
adminEditingSince DateTime?
steps MapVetoStep[]
steps MapVoteStep[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MapVetoStep {
model MapVoteStep {
id String @id @default(uuid())
vetoId String
voteId String
order Int
action MapVetoAction
action MapVoteAction
// Team, das am Zug ist (kann bei DECIDER null sein)
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?
chosenAt DateTime?
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([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 || ''))
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 {
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 tx.mapVetoStep.deleteMany({ where: { veto: { matchId } } })
await tx.mapVeto.deleteMany({ where: { matchId } })
await tx.mapVoteStep.deleteMany({ where: { vote: { matchId } } })
await tx.mapVote.deleteMany({ where: { matchId } })
await tx.playerStats.deleteMany({ where: { matchId } })
await tx.matchPlayer.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 { authOptions } from '@/app/lib/auth'
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 { MAP_OPTIONS } from '@/app/lib/mapOptions'
/** gleicher Pool wie in deiner mapvote-Route */
const ACTIVE_DUTY: string[] = [
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
]
// ---- Pool aus MAP_OPTIONS ableiten (nur "de_*", ohne Sonderkarten) ----
const MAP_POOL: string[] = MAP_OPTIONS
.filter(m => m.key.startsWith('de_') && m.key !== 'lobby_mapvote')
.map(m => m.key)
/** identische Logik wie in deiner mapvote-Route */
function vetoOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
// identisch zu mapvote-Route
function voteOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
const base = match.matchDate ?? match.demoDate ?? new Date()
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
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) {
return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 4, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 5, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 6, action: MapVetoAction.DECIDER, teamId: null },
{ order: 0, action: MapVoteAction.BAN, teamId: firstId },
{ order: 1, action: MapVoteAction.BAN, teamId: secondId },
{ order: 2, action: MapVoteAction.PICK, teamId: firstId },
{ order: 3, action: MapVoteAction.PICK, teamId: secondId },
{ order: 4, action: MapVoteAction.BAN, teamId: firstId },
{ order: 5, action: MapVoteAction.BAN, teamId: secondId },
{ order: 6, action: MapVoteAction.DECIDER, teamId: null },
] as const
}
// BO5
return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 4, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 5, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 6, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 0, action: MapVoteAction.BAN, teamId: firstId },
{ order: 1, action: MapVoteAction.BAN, teamId: secondId },
{ order: 2, action: MapVoteAction.PICK, teamId: firstId },
{ order: 3, action: MapVoteAction.PICK, teamId: secondId },
{ order: 4, action: MapVoteAction.PICK, teamId: firstId },
{ order: 5, action: MapVoteAction.PICK, teamId: secondId },
{ order: 6, action: MapVoteAction.PICK, teamId: firstId },
] as const
}
@ -50,7 +52,6 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
// Match laden (inkl. Teams & BestOf für Steps)
const match = await prisma.match.findUnique({
where: { id: matchId },
select: {
@ -60,29 +61,32 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
demoDate: true,
teamA: { 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 })
}
const bestOf = match.bestOf ?? 3
const stepsDef = buildSteps(bestOf, match.teamA.id, match.teamB.id)
const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const bestOf = match.bestOf ?? 3
// ---- Zufälliges Startteam bestimmen ----
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) => {
if (match.mapVeto?.id) {
await tx.mapVetoStep.deleteMany({ where: { vetoId: match.mapVeto.id } })
await tx.mapVeto.delete({ where: { matchId } })
if (match.mapVote?.id) {
await tx.mapVoteStep.deleteMany({ where: { voteId: match.mapVote.id } })
await tx.mapVote.delete({ where: { matchId } })
}
await tx.mapVeto.create({
await tx.mapVote.create({
data: {
matchId,
bestOf,
mapPool: ACTIVE_DUTY,
mapPool: MAP_POOL, // <- aus MAP_OPTIONS
currentIdx: 0,
locked: false,
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 })
return NextResponse.json({ ok: true })
}

View File

@ -3,27 +3,37 @@ import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { MapVetoAction } from '@/generated/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { randomInt } from 'crypto'
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
import { createHash } from 'crypto'
/* -------------------- Konstanten -------------------- */
const ACTIVE_DUTY: string[] = [
'de_inferno','de_mirage','de_nuke','de_overpass','de_vertigo','de_ancient','de_anubis',
]
const ACTION_MAP: Record<MapVetoAction, 'ban'|'pick'|'decider'> = {
const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
BAN: 'ban', PICK: 'pick', DECIDER: 'decider',
}
/* -------------------- 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()
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]
}
@ -52,8 +62,8 @@ function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
] as const
}
function shapeState(veto: any) {
const steps = [...veto.steps]
function shapeState(vote: any) {
const steps = [...vote.steps]
.sort((a, b) => a.order - b.order)
.map((s: any) => ({
order : s.order,
@ -65,12 +75,20 @@ function shapeState(veto: any) {
}))
return {
bestOf : veto.bestOf,
mapPool : veto.mapPool as string[],
currentIndex: veto.currentIdx,
locked : veto.locked as boolean,
opensAt : veto.opensAt ? new Date(veto.opensAt).toISOString() : null,
bestOf : vote.bestOf,
mapPool : vote.mapPool as string[],
currentIndex: vote.currentIdx,
locked : vote.locked as boolean,
opensAt : vote.opensAt ? new Date(vote.opensAt).toISOString() : null,
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)
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 host = (req.headers.get('x-forwarded-host') || req.headers.get('host') || '').split(',')[0].trim()
return `${proto}://${host}`
@ -115,10 +132,8 @@ async function fetchTeamApi(teamId: string | null | undefined, req: NextRequest)
try {
const r = await fetch(url, {
// interne Server-Fetches dürfen nicht gecacht werden
cache: 'no-store',
headers: {
// Forward auth/proxy headers, falls nötig (nicht zwingend)
'x-forwarded-proto': req.headers.get('x-forwarded-proto') || '',
'x-forwarded-host' : req.headers.get('x-forwarded-host') || '',
}
@ -129,7 +144,7 @@ async function fetchTeamApi(teamId: string | null | undefined, req: NextRequest)
id: string
name?: string | null
logo?: string | null
leader?: string | null // LeaderId
leader?: string | null
activePlayers: any[]
inactivePlayers: 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) {
const leaderFromMatch = shapeLeader(matchTeam?.leader ?? null)
if (leaderFromMatch) return leaderFromMatch
@ -156,13 +199,12 @@ function resolveLeaderPlayer(matchTeam: any | null | undefined, teamApi: any | n
return shapePlayer(found) ?? { steamId: leaderId, name: '', avatar: '' }
}
async function ensureVeto(matchId: string) {
async function ensureVote(matchId: string) {
const match = await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA : {
include: {
// Leader-Relation als Objekt laden
leader: {
select: {
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?
if (match.mapVeto) return { match, veto: match.mapVeto }
if (match.mapVote) return { match, vote: match.mapVote }
// Neu anlegen
const bestOf = match.bestOf ?? 3
const mapPool = ACTIVE_DUTY
const opensAt = vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const stepsDef = buildSteps(bestOf, match.teamA!.id, match.teamB!.id)
const mapPool = MAP_OPTIONS.map(m => m.key)
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
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: {
matchId : match.id,
bestOf,
@ -214,7 +260,7 @@ async function ensureVeto(matchId: string) {
steps : {
create: stepsDef.map(s => ({
order : s.order,
action: s.action as MapVetoAction,
action: s.action as MapVoteAction,
teamId: s.teamId,
})),
},
@ -222,7 +268,7 @@ async function ensureVeto(matchId: string) {
include: { steps: true },
})
return { match, veto: created }
return { match, vote: created }
}
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))
}
// 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),
])
/* ---------- Visuals: deterministisches zufälliges Bild pro Map & Match ---------- */
const teamAPlayers = (teamAApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean)
const teamBPlayers = (teamBApi?.activePlayers ?? []).map(shapePlayer).filter(Boolean)
function buildMapVisuals(matchId: string, mapPool: string[]) {
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 {
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,
},
if (imgs.length > 0) {
// deterministischer Index auf Basis von matchId+key
const h = createHash('sha256').update(`${matchId}:${key}`).digest('hex')
const n = parseInt(h.slice(0, 8), 16) // 32-bit
const idx = n % imgs.length
bg = imgs[idx]
}
visuals[key] = { label, bg } // images optional mitgeben: { label, bg, images: imgs }
}
return visuals
}
/* -------------------- GET -------------------- */
@ -265,13 +306,14 @@ export async function GET(req: NextRequest, { params }: { params: { matchId: str
const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
const { match, veto } = await ensureVeto(matchId)
if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const { match, vote } = await ensureVote(matchId)
if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const teams = await buildTeamsPayload(match, req)
const mapVisuals = buildMapVisuals(match.id, vote.mapPool)
return NextResponse.json(
{ ...shapeState(veto), teams },
{ ...shapeState(vote), mapVisuals, teams },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (e) {
@ -290,43 +332,61 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
const matchId = params.matchId
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 {
const { match, veto } = await ensureVeto(matchId)
if (!match || !veto) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const { match, vote } = await ensureVote(matchId)
if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
// Öffnungsfenster (1h vor Match-/Demo-Beginn)
const opensAt = veto.opensAt ?? vetoOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const isOpen = new Date() >= new Date(opensAt)
if (!isOpen && !me.isAdmin) return NextResponse.json({ message: 'Veto ist noch nicht offen' }, { status: 403 })
/* -------- Admin-Edit umschalten (früher Exit) -------- */
if (typeof body.adminEdit === 'boolean') {
if (!me.isAdmin) {
return NextResponse.json({ message: 'Nur Admins dürfen den Edit-Mode setzen' }, { status: 403 })
}
// Schon abgeschlossen?
if (veto.locked) return NextResponse.json({ message: 'Veto bereits abgeschlossen' }, { status: 409 })
const updated = await setAdminEdit(vote.id, body.adminEdit ? me.steamId : null)
// 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 })
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
if (current.action === 'DECIDER') {
@ -335,30 +395,30 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
}
const lastMap = available[0]
await prisma.$transaction(async (tx) => {
await tx.mapVetoStep.update({
await tx.mapVoteStep.update({
where: { id: current.id },
data : { map: lastMap, chosenAt: new Date(), chosenBy: me.steamId },
})
await tx.mapVeto.update({
where: { id: veto.id },
data : { currentIdx: veto.currentIdx + 1, locked: true },
await tx.mapVote.update({
where: { id: vote.id },
data : { currentIdx: vote.currentIdx + 1, locked: true },
})
})
const updated = await prisma.mapVeto.findUnique({
where: { id: veto.id },
const updated = await prisma.mapVote.findUnique({
where: { id: vote.id },
include: { steps: true },
})
// 🔔 Broadcast (flat)
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), 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 isLeaderB = !!(match as any).teamB?.leaderId && (match as any).teamB.leaderId === me.steamId
const allowed = me.isAdmin || (current.teamId && (
@ -370,20 +430,20 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
// Payload validieren
const map = body.map?.trim()
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 })
// Schritt setzen & ggf. weiterdrehen (+ Decider evtl. auto)
await prisma.$transaction(async (tx) => {
// aktuellen Schritt setzen
await tx.mapVetoStep.update({
await tx.mapVoteStep.update({
where: { id: current.id },
data : { map, chosenAt: new Date(), chosenBy: me.steamId },
})
// neuen Zustand ermitteln
const after = await tx.mapVeto.findUnique({
where : { id: veto.id },
const after = await tx.mapVote.findUnique({
where : { id: vote.id },
include: { steps: true },
})
if (!after) return
@ -397,7 +457,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
if (next?.action === 'DECIDER') {
const avail = computeAvailableMaps(after.mapPool, stepsAfter)
if (avail.length === 1) {
await tx.mapVetoStep.update({
await tx.mapVoteStep.update({
where: { id: next.id },
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))
if (idx > maxOrder) locked = true
await tx.mapVeto.update({
await tx.mapVote.update({
where: { id: after.id },
data : { currentIdx: idx, locked },
})
})
const updated = await prisma.mapVeto.findUnique({
where : { id: veto.id },
const updated = await prisma.mapVote.findUnique({
where : { id: vote.id },
include: { steps: true },
})
// 🔔 Broadcast (flat)
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), teams })
return NextResponse.json({ ...shapeState(updated), mapVisuals, teams })
} catch (e) {
console.error('[map-vote][POST] error', e)
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 })
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 {
const match = await prisma.match.findUnique({
@ -20,7 +20,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
include: {
teamA: { include: { leader: true } },
teamB: { include: { leader: true } },
mapVeto: true,
mapVote: true,
},
})
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
}
const lead = Number.isFinite(Number(vetoLeadMinutes)) ? Number(vetoLeadMinutes) : 60
const lead = Number.isFinite(Number(voteLeadMinutes)) ? Number(voteLeadMinutes) : 60
let opensAt: Date | null = null
if (updateData.matchDate instanceof Date) {
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({
where: { id },
data: updateData,
include: { mapVeto: true },
include: { mapVote: true },
})
if (opensAt) {
if (!m.mapVeto) {
await tx.mapVeto.create({
if (!m.mapVote) {
await tx.mapVote.create({
data: {
matchId: m.id,
opensAt,
@ -67,8 +67,8 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
},
})
} else {
await tx.mapVeto.update({
where: { id: m.mapVeto.id },
await tx.mapVote.update({
where: { id: m.mapVote.id },
data: { opensAt },
})
}
@ -79,7 +79,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
include: {
teamA: { 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({
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({
@ -104,7 +104,7 @@ export async function PUT(req: NextRequest, { params }: { params: { matchId: str
teamBId: updated.teamBId,
matchDate: updated.matchDate,
map: updated.map,
mapVeto: updated.mapVeto,
mapVote: updated.mapVote,
}, { headers: { 'Cache-Control': 'no-store' } })
} catch (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 { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { MapVetoAction } from '@/generated/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const dynamic = 'force-dynamic'
@ -12,24 +12,24 @@ export const dynamic = 'force-dynamic'
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
if (bestOf === 5) {
return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 4, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 5, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 6, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 0, action: MapVoteAction.BAN, teamId: teamAId },
{ order: 1, action: MapVoteAction.BAN, teamId: teamBId },
{ order: 2, action: MapVoteAction.PICK, teamId: teamAId },
{ order: 3, action: MapVoteAction.PICK, teamId: teamBId },
{ order: 4, action: MapVoteAction.PICK, teamId: teamAId },
{ order: 5, action: MapVoteAction.PICK, teamId: teamBId },
{ order: 6, action: MapVoteAction.PICK, teamId: teamAId },
] as const
}
// default BO3
return [
{ order: 0, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 1, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 2, action: MapVetoAction.PICK, teamId: teamAId },
{ order: 3, action: MapVetoAction.PICK, teamId: teamBId },
{ order: 4, action: MapVetoAction.BAN, teamId: teamAId },
{ order: 5, action: MapVetoAction.BAN, teamId: teamBId },
{ order: 6, action: MapVetoAction.DECIDER, teamId: null },
{ order: 0, action: MapVoteAction.BAN, teamId: teamAId },
{ order: 1, action: MapVoteAction.BAN, teamId: teamBId },
{ order: 2, action: MapVoteAction.PICK, teamId: teamAId },
{ order: 3, action: MapVoteAction.PICK, teamId: teamBId },
{ order: 4, action: MapVoteAction.BAN, teamId: teamAId },
{ order: 5, action: MapVoteAction.BAN, teamId: teamBId },
{ order: 6, action: MapVoteAction.DECIDER, teamId: null },
] as const
}
@ -146,12 +146,12 @@ export async function POST (req: NextRequest) {
await tx.matchPlayer.createMany({ data: playersData, skipDuplicates: true })
}
// 6) MapVeto anlegen
// 6) MapVote anlegen
const baseDate = newMatch.demoDate ?? plannedAt
const opensAt = new Date(baseDate.getTime() - 60 * 60 * 1000)
const stepsDef = buildSteps(bestOfInt, teamAId, teamBId)
await tx.mapVeto.create({
await tx.mapVote.create({
data: {
matchId : newMatch.id,
bestOf : bestOfInt,

View File

@ -13,7 +13,7 @@ export async function GET(req: Request) {
teamA : true,
teamB : 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 opensInMinutes: number | null = null // <-- optional
if (m.mapVeto) {
const stepsSorted = [...m.mapVeto.steps].sort((a, b) => a.order - b.order)
if (m.mapVote) {
const stepsSorted = [...m.mapVote.steps].sort((a, b) => a.order - b.order)
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 =
m.mapVeto.opensAt ??
m.mapVote.opensAt ??
(() => {
const base = m.matchDate ?? m.demoDate ?? new Date()
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))
}
currentIndex = m.mapVeto.currentIdx
const cur = stepsSorted.find(s => s.order === m.mapVeto?.currentIdx)
currentIndex = m.mapVote.currentIdx
const cur = stepsSorted.find(s => s.order === m.mapVote?.currentIdx)
currentAction = (cur?.action as 'BAN'|'PICK'|'DECIDER') ?? null
decidedCount = stepsSorted.filter(s => !!s.chosenAt).length
totalSteps = stepsSorted.length
@ -68,7 +68,7 @@ export async function GET(req: Request) {
scoreB : m.scoreB,
winnerTeam: m.winnerTeam ?? null,
mapVeto: m.mapVeto ? {
mapVote: m.mapVote ? {
status,
opensAt: opensAtISO,
isOpen,

View File

@ -10,9 +10,9 @@ type ButtonProps = {
modalId?: string
color?: 'blue' | 'red' | 'gray' | 'green' | 'teal' | 'transparent'
variant?: 'solid' | 'outline' | 'ghost' | 'soft' | 'white' | 'link'
size?: 'xs' |'sm' | 'md' | 'lg'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
className?: string
dropDirection?: "up" | "down" | "auto"
dropDirection?: 'up' | 'down' | 'auto'
disabled?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>
@ -27,7 +27,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
variant = 'solid',
size = 'md',
className,
dropDirection = "down",
dropDirection = 'down',
disabled = false,
...rest
},
@ -52,12 +52,14 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
sm: 'py-2 px-3',
md: 'py-3 px-4',
lg: 'p-4 sm:p-5',
xl: 'py-6 px-8 text-lg',
full: 'py-6 px-8 text-lg w-full',
}
const base = `
${sizeClasses[size] || sizeClasses['md']}
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>> = {
@ -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',
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',
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: {
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',
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',
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 = `
${base}
${variants[variant]?.[color] || variants.solid.blue}
${safeVariantClasses}
${className || ''}
`
useEffect(() => {
if (open && dropDirection === "auto" && buttonRef.current) {
if (open && dropDirection === 'auto' && buttonRef.current) {
requestAnimationFrame(() => {
const rect = buttonRef.current!.getBoundingClientRect();
const dropdownHeight = 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const rect = buttonRef.current!.getBoundingClientRect()
const dropdownHeight = 200
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
setDirection("up");
setDirection('up')
} else {
setDirection("down");
setDirection('down')
}
});
})
}
}, [open, dropDirection]);
}, [open, dropDirection])
const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
const next = !open
setOpen(next)
onToggle?.(next)
onClick?.(event)
}
}
return (
<button
@ -147,6 +160,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
type="button"
className={classes}
onClick={toggle}
disabled={disabled}
{...modalAttributes}
{...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">
{dayLabel}<br />{dateKey}
</div>
{dayMatches.map(m => {
{dayMatches.map((m: Match) => {
const started = new Date(m.demoDate).getTime() <= Date.now()
const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null
const isLive = started && unfinished
@ -273,23 +273,23 @@ export default function CommunityMatchList({ matchType }: Props) {
</span>
)}
{/* Map-Veto Badge */}
{m.mapVeto && (
{/* Map-Vote Badge */}
{m.mapVote && (
<span
className={`
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={
m.mapVeto.opensAt
? `Öffnet ${format(new Date(m.mapVeto.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr`
m.mapVote.opensAt
? `Öffnet ${format(new Date(m.mapVote.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr`
: undefined
}
>
{m.mapVeto.isOpen
? (m.mapVeto.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen')
: m.mapVeto.opensAt
? `Map-Vote ab ${format(new Date(m.mapVeto.opensAt), 'HH:mm', { locale: de })} Uhr`
{m.mapVote.isOpen
? (m.mapVote.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen')
: m.mapVote.opensAt
? `Map-Vote ab ${format(new Date(m.mapVote.opensAt), 'HH:mm', { locale: de })} Uhr`
: 'Map-Vote bald'}
</span>
)}

View File

@ -20,7 +20,7 @@ type Props = {
defaultTeamBName?: string | null
defaultDateISO?: string | null
defaultMap?: string | null
defaultVetoLeadMinutes?: number
defaultVoteLeadMinutes?: number
onSaved?: () => void
}
@ -35,7 +35,7 @@ export default function EditMatchMetaModal({
defaultTeamBName,
defaultDateISO,
defaultMap,
defaultVetoLeadMinutes = 60,
defaultVoteLeadMinutes = 60,
onSaved,
}: Props) {
// -------- state
@ -48,8 +48,8 @@ export default function EditMatchMetaModal({
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
})
const [mapKey, setMapKey] = useState<string>(defaultMap ?? 'lobby_mapveto')
const [vetoLead, setVetoLead] = useState<number>(defaultVetoLeadMinutes)
const [mapKey, setMapKey] = useState<string>(defaultMap ?? 'lobby_mapvote')
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
const [teams, setTeams] = useState<TeamOption[]>([])
const [loadingTeams, setLoadingTeams] = useState(false)
@ -83,8 +83,8 @@ export default function EditMatchMetaModal({
setTitle(defaultTitle ?? '')
setTeamAId(defaultTeamAId ?? '')
setTeamBId(defaultTeamBId ?? '')
setMapKey(defaultMap ?? 'lobby_mapveto')
setVetoLead(defaultVetoLeadMinutes)
setMapKey(defaultMap ?? 'lobby_mapvote')
setVoteLead(defaultVoteLeadMinutes)
if (defaultDateISO) {
const d = new Date(defaultDateISO)
const pad = (n: number) => String(n).padStart(2, '0')
@ -101,7 +101,7 @@ export default function EditMatchMetaModal({
defaultTeamBId,
defaultDateISO,
defaultMap,
defaultVetoLeadMinutes,
defaultVoteLeadMinutes,
])
// -------- derived: options
@ -143,7 +143,7 @@ export default function EditMatchMetaModal({
teamBId: teamBId || null,
matchDate: date ? new Date(date).toISOString() : 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`, {
@ -253,18 +253,18 @@ export default function EditMatchMetaModal({
/>
</div>
{/* Veto-Lead */}
{/* Vote-Lead */}
<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
type="number"
min={0}
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
value={vetoLead}
onChange={e => setVetoLead(Number(e.target.value))}
value={voteLead}
onChange={e => setVoteLead(Number(e.target.value))}
/>
<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>
</div>
</div>

View File

@ -1,14 +1,14 @@
// MapVetoBanner.tsx
// MapVoteBanner.tsx
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore'
import type { MapVetoState } from '../types/mapveto'
import type { MapVoteState } from '../types/mapvote'
type Props = { match: any; initialNow: number }
export default function MapVetoBanner({ match, initialNow }: Props) {
export default function MapVoteBanner({ match, initialNow }: Props) {
const router = useRouter()
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
@ -16,7 +16,7 @@ export default function MapVetoBanner({ match, initialNow }: Props) {
// ✅ eine Uhr, deterministisch bei Hydration (kommt als Prop vom Server)
const [now, setNow] = useState(initialNow)
const [state, setState] = useState<MapVetoState | null>(null)
const [state, setState] = useState<MapVoteState | null>(null)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
@ -130,7 +130,7 @@ export default function MapVetoBanner({ match, initialNow }: Props) {
<div className="shrink-0">
{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">
Veto abgeschlossen
Voting abgeschlossen
</span>
) : 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">

View File

@ -1,16 +1,18 @@
// /app/components/MapVetoPanel.tsx
// /app/components/MapVotePanel.tsx
'use client'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import type React from 'react'
import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore'
import { MAP_OPTIONS } from '../lib/mapOptions'
import MapVoteProfileCard from './MapVetoProfileCard'
import MapVoteProfileCard from './MapVoteProfileCard'
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 { Player } from '../types/team'
import Image from 'next/image'
import LoadingSpinner from './LoadingSpinner'
type Props = { match: Match }
@ -20,13 +22,15 @@ const getTeamLogo = (logo?: string | null) =>
const HOLD_MS = 1200
const COMPLETE_THRESHOLD = 1.0
export default function MapVetoPanel({ match }: Props) {
export default function MapVotePanel({ match }: Props) {
const { data: session } = useSession()
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 [error, setError] = useState<string | null>(null)
const [adminEditMode, setAdminEditMode] = useState(false)
// --- Zeitpunkt: 1h vor Match-/Demo-Beginn (Fallback) ---
const opensAtTs = useMemo(() => {
@ -48,16 +52,20 @@ export default function MapVetoPanel({ match }: Props) {
const isLeaderA = !!me?.steamId && match.teamA?.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(
(teamId?: string | null) => {
if (!teamId) return false
if (isAdmin) return true
return (
(teamId === match.teamA?.id && isLeaderA) ||
(teamId === match.teamB?.id && isLeaderB)
)
},
[isAdmin, isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id],
[isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id],
)
// --- Laden / Reload ---
@ -83,19 +91,23 @@ export default function MapVetoPanel({ match }: Props) {
}
}, [match.id])
useEffect(() => {
load()
}, [load])
useEffect(() => { load() }, [load])
// --- SSE: live nachladen ---
useEffect(() => {
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
if (matchId !== match.id) return
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 ---
const opensAt = useMemo(
() => (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 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 ?? []
@ -120,7 +136,7 @@ export default function MapVetoPanel({ match }: Props) {
return map
}, [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 ---
const handlePickOrBan = async (map: string) => {
@ -137,7 +153,7 @@ export default function MapVetoPanel({ match }: Props) {
return
}
// ⬅️ Optimistisches Update, bevor SSE kommt:
// Optimistisches Update
setState(prev =>
prev
? {
@ -148,12 +164,25 @@ export default function MapVetoPanel({ match }: Props) {
}
: prev
)
} catch {
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) ---
const rafRef = 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) ---
const playersA = useMemo<MatchPlayer[]>(() => {
// 0) Bevorzugt: bereits vorbereitete Team-Spieler am Match selbst
const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
// 1) Klassischer Weg: match.players via Roster (teamAUsers) filtern
const all = (match as any).players as MatchPlayer[] | undefined
const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined
if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) {
@ -261,34 +288,28 @@ export default function MapVetoPanel({ match }: Props) {
return all.filter(p => setA.has(p.user.steamId))
}
// 2) Fallback: teamId am Player (falls vorhanden)
if (Array.isArray(all) && match.teamA?.id) {
return all.filter(p => (p as any).team?.id === match.teamA?.id)
}
// 3) Letzter Fallback: aus dem Veto-State (kommt aus /mapvote)
const vetoPlayers = state?.teams?.teamA?.players as
const votePlayers = state?.teams?.teamA?.players as
| Array<{ steamId: string; name?: string | null; avatar?: string | null }>
| undefined
if (Array.isArray(vetoPlayers) && vetoPlayers.length) {
return vetoPlayers.map((p): MatchPlayer => ({
if (Array.isArray(votePlayers) && votePlayers.length) {
return votePlayers.map((p): MatchPlayer => ({
user: {
steamId: p.steamId,
name: p.name ?? 'Unbekannt',
avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
},
// wichtig: undefined statt null
stats: undefined,
// falls dein MatchPlayer einen string akzeptiert:
// team: (match as any)?.teamA?.name ?? 'Team A',
}))
}
return []
}, [match, state?.teams?.teamA?.players])
// ⬇️ ersetzt den bisherigen playersB-Block
const playersB = useMemo<MatchPlayer[]>(() => {
const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined
if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers
@ -304,95 +325,170 @@ export default function MapVetoPanel({ match }: Props) {
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 }>
| undefined
if (Array.isArray(vetoPlayers) && vetoPlayers.length) {
return vetoPlayers.map((p): MatchPlayer => ({
if (Array.isArray(votePlayers) && votePlayers.length) {
return votePlayers.map((p): MatchPlayer => ({
user: {
steamId: p.steamId,
name: p.name ?? 'Unbekannt',
avatar: p.avatar ?? '/assets/img/avatars/default_steam_avatar.jpg',
},
stats: undefined,
// team: (match as any)?.teamB?.name ?? 'Team B',
}))
}
return []
}, [match, state?.teams?.teamB?.players])
const 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 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 (
<div className="p-4">
{showLoading ? (
<div className="p-4">Lade Map-Voting</div>
<div className="p-4">
<LoadingSpinner />
</div>
) : showError ? (
<div className="p-4 text-red-600">{error}</div>
) : (
<>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Map-Vote</h3>
<div className="flex items-center gap-2">
<div className="grid grid-cols-3 items-center mb-3">
{/* Linke Spalte */}
<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">
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
</div>
{isAdmin && (
<Button
color="red"
variant="outline"
size="sm"
className="ml-3"
title="Map-Vote zurücksetzen"
onClick={async () => {
if (!confirm('Map-Vote wirklich zurücksetzen? Alle bisherigen Picks/Bans gehen verloren.')) return
try {
const r = await fetch(`/api/matches/${match.id}/mapvote/reset`, { method: 'POST' })
if (!r.ok) {
const j = await r.json().catch(() => ({}))
alert(j.message ?? 'Reset fehlgeschlagen')
return
<>
<Button
color={adminEditMode ? 'teal' : 'gray'}
variant={adminEditMode ? 'solid' : 'outline'}
size="sm"
className="ml-2"
title={adminEditMode ? 'Admin-Bearbeitung beenden' : 'Map-Vote als Admin bearbeiten'}
onClick={async () => {
// Optimistisch lokal toggeln
const next = !adminEditMode
setAdminEditMode(next)
try {
await postAdminEdit(next) // globaler Freeze on/off
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 {
alert('Netzwerkfehler beim Reset')
}
}}
/>
}}
>
{adminEditMode ? 'Bearbeiten: AN' : 'Bearbeiten'}
</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>
{/* Countdown / Status */}
{!isOpen && (
<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">
<div className="mb-4 flex flex-col items-center gap-2">
{state?.locked ? (
<span className="block text-lg font-semibold px-3 py-2 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 text-center">
Veto abgeschlossen
Voting abgeschlossen
</span>
) : 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">
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 className="block text-lg font-semibold px-3 py-2 rounded bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200 text-center">
Wartet auf&nbsp;
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
&nbsp;(Leader/Admin)
{currentStep?.teamId === match.teamA?.id
? match.teamA.name
: match.teamB.name}
</span>
)
) : (
@ -402,7 +498,7 @@ export default function MapVetoPanel({ match }: Props) {
)}
{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}
</span>
)}
@ -410,9 +506,25 @@ export default function MapVetoPanel({ match }: Props) {
{/* Hauptbereich */}
{state && (
<div className="mt-2 flex items-start gap-4 justify-between">
{/* Links Team A */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56">
<div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start">
{/* Linke Spalte Team A */}
<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) => (
<MapVoteProfileCard
key={p.user.steamId}
@ -421,6 +533,7 @@ export default function MapVetoPanel({ match }: Props) {
avatar={p.user.avatar}
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={
(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) ===
p.user.steamId
@ -432,12 +545,12 @@ export default function MapVetoPanel({ match }: Props) {
}
/>
))}
</aside>
</div>
{/* Mitte Maps (Hold-to-confirm) */}
<main className="max-w-sm flex-shrink-0">
<ul className="flex flex-col gap-1.5">
{mapPool.map((map) => {
{/* Mitte Mappool */}
<main className="w-full flex-1 max-w-xl">
<ul className="flex flex-col gap-3">
{sortedMapPool.map((map) => {
const decision = decisionByMap.get(map)
const status = decision?.action ?? null // 'ban' | 'pick' | 'decider' | null
const teamId = decision?.teamId ?? null
@ -449,28 +562,13 @@ export default function MapVetoPanel({ match }: Props) {
const intent = isAvailable ? currentStep?.action : null
const intentStyles =
intent === 'ban'
? {
ring: '',
border: '',
hover: 'hover:bg-red-50 dark:hover:bg-red-950',
progress: 'bg-red-200/60 dark:bg-red-800/40',
}
? { hover: 'hover:bg-red-50 dark:hover:bg-red-950', progress: 'bg-red-200/60 dark:bg-red-800/40' }
: intent === 'pick'
? {
ring: '',
border: '',
hover: 'hover:bg-green-50 dark:hover:bg-green-950',
progress: 'bg-green-200/60 dark:bg-green-800/40',
}
: {
ring: '',
border: '',
hover: 'hover:bg-blue-50 dark:hover:bg-blue-950',
progress: 'bg-blue-200/60 dark:bg-blue-800/40',
}
? { hover: 'hover:bg-green-50 dark:hover:bg-green-950', progress: 'bg-green-200/60 dark:bg-green-800/40' }
: { hover: 'hover:bg-blue-50 dark:hover:bg-blue-950', progress: 'bg-blue-200/60 dark:bg-blue-800/40' }
const baseClasses =
'relative flex items-center justify-between gap-2 rounded-md border p-2.5 transition select-none'
'relative flex items-center justify-between gap-2 rounded-md border border-neutral-500 p-2.5 transition select-none'
const visualTaken =
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-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700'
const visualAvailable = `bg-white dark:bg-neutral-900 ${intentStyles.border} ring-1 ${intentStyles.ring} ${intentStyles.hover} cursor-pointer`
const visualDisabled = 'bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700'
const 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 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
// Decider-Team bestimmen (falls nötig)
@ -497,26 +595,44 @@ export default function MapVetoPanel({ match }: Props) {
const progress = progressByMap[map] ?? 0
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 (
<li
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 */}
{pickedByA ? (
<img
src={getTeamLogo(match.teamA?.logo)}
alt={match.teamA?.name ?? 'Team A'}
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900"
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
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}
size="full"
title={
taken
? status === 'ban'
@ -526,7 +642,7 @@ export default function MapVetoPanel({ match }: Props) {
: 'Decider'
: isAvailable
? 'Zum Bestätigen gedrückt halten'
: 'Nur der Team-Leader (oder Admin) darf wählen'
: disabledTitle
}
onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)}
@ -535,27 +651,104 @@ export default function MapVetoPanel({ match }: Props) {
onTouchEnd={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 */}
{showProgress && (
<span
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)}%` }}
/>
)}
{/* Inhalt */}
<div className="flex-1 min-w-0 relative z-[1] flex flex-col items-center justify-center text-center">
<span className="text-[13px] font-medium truncate">{fmt(map)}</span>
{/* Fixe Ban/Pick-Pills bei bereits entschiedenen Maps (inkl. Decider = Pick) */}
{taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
<>
{/* 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' && (
<span
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
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"
>
<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
src={getTeamLogo(match.teamB?.logo)}
alt={match.teamB?.name ?? 'Team B'}
className="w-6 h-6 rounded-full border bg-white dark:bg-neutral-900"
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>
)
@ -581,8 +778,24 @@ export default function MapVetoPanel({ match }: Props) {
</ul>
</main>
{/* Rechts Team B */}
<aside className="hidden lg:flex lg:flex-col gap-2 w-56">
{/* Rechte Spalte Team B */}
<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) => (
<MapVoteProfileCard
key={p.user.steamId}
@ -591,6 +804,7 @@ export default function MapVetoPanel({ match }: Props) {
avatar={p.user.avatar}
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={
(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) ===
p.user.steamId
@ -602,7 +816,7 @@ export default function MapVetoPanel({ match }: Props) {
}
/>
))}
</aside>
</div>
</div>
)}
</>

View File

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

View File

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

View File

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

View File

@ -200,7 +200,7 @@ export default function UserMatchesList({ steamId }: { steamId: string }) {
{matches.map(m => {
const mapInfo =
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 ownCTSide = m.team !== 'T'

View File

@ -1,25 +1,73 @@
// 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[] = [
{ key: 'de_train', label: 'Train' },
{ key: 'ar_baggage', label: 'Baggage' },
{ key: 'ar_pool_day', label: 'Pool Day' },
{ key: 'ar_shoots', label: 'Shoots' },
{ key: 'cs_agency', label: 'Agency' },
{ key: 'cs_italy', label: 'Italy' },
{ key: 'cs_office', label: 'Office' },
{ key: 'de_ancient', label: 'Ancient' },
{ key: 'de_anubis', label: 'Anubis' },
{ key: 'de_brewery', label: 'Brewery' },
{ key: 'de_dogtown', label: 'Dogtown' },
{ key: 'de_dust2', label: 'Dust 2' },
{ key: 'de_grail', label: 'Grail' },
{ key: 'de_inferno', label: 'Inferno' },
{ key: 'de_jura', label: 'Jura' },
{ key: 'de_mirage', label: 'Mirage' },
{ key: 'de_nuke', label: 'Nuke' },
{ key: 'de_overpass', label: 'Overpass' },
{ key: 'de_vertigo', label: 'Vertigo' },
{ key: 'lobby_mapveto', label: 'Pick/Ban' },
{
key: 'de_train',
label: 'Train',
images: [
'/assets/img/maps/de_train/1.jpg',
'/assets/img/maps/de_train/2.jpg',
],
},
{
key: 'de_dust2',
label: 'Dust 2',
images: [
'/assets/img/maps/de_dust2/1.jpg',
'/assets/img/maps/de_dust2/2.jpg',
],
},
{
key: 'de_mirage',
label: 'Mirage',
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',
'team-invite-revoked',
'map-vote-updated',
'map-vote-admin-edit',
'match-created',
'matches-updated',
'match-deleted',
'match-updated',
// neu: gezieltes Event, wenn sich die Aufstellung ändert
'match-lineup-updated',
] as const;
@ -67,6 +66,8 @@ export const MATCH_EVENTS: ReadonlySet<SSEEventType> = new Set([
'match-deleted',
'match-lineup-updated',
'match-updated',
'map-vote-updated',
'map-vote-admin-edit',
]);
// Event-Typen, die das NotificationCenter betreffen

View File

@ -1,10 +1,10 @@
// app/match-details/[matchId]/vote/VoteClient.tsx
'use client'
import MapVetoPanel from '@/app/components/MapVetoPanel'
import MapVotePanel from '@/app/components/MapVotePanel'
import { useMatch } from '../MatchContext' // aus dem Layout-Context
export default function VoteClient() {
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
teamB: Team
mapVeto?: {
mapVote?: {
status: 'not_started' | 'in_progress' | 'completed' | null
opensAt: string | 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 = {}
/**
* Prisma Client JS version: 6.13.0
* Query Engine version: 361e86d0ea4987e9f53a565309b3eed797a6bcbd
* Prisma Client JS version: 6.14.0
* Query Engine version: 717184b7b35ea05dfa71a3236b7af656013e1e49
*/
Prisma.prismaVersion = {
client: "6.13.0",
engine: "361e86d0ea4987e9f53a565309b3eed797a6bcbd"
client: "6.14.0",
engine: "717184b7b35ea05dfa71a3236b7af656013e1e49"
}
Prisma.PrismaClientKnownRequestError = () => {
@ -276,7 +276,7 @@ exports.Prisma.ServerRequestScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.MapVetoScalarFieldEnum = {
exports.Prisma.MapVoteScalarFieldEnum = {
id: 'id',
matchId: 'matchId',
bestOf: 'bestOf',
@ -284,13 +284,15 @@ exports.Prisma.MapVetoScalarFieldEnum = {
currentIdx: 'currentIdx',
locked: 'locked',
opensAt: 'opensAt',
adminEditingBy: 'adminEditingBy',
adminEditingSince: 'adminEditingSince',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.MapVetoStepScalarFieldEnum = {
exports.Prisma.MapVoteStepScalarFieldEnum = {
id: 'id',
vetoId: 'vetoId',
voteId: 'voteId',
order: 'order',
action: 'action',
teamId: 'teamId',
@ -332,7 +334,7 @@ exports.ScheduleStatus = exports.$Enums.ScheduleStatus = {
COMPLETED: 'COMPLETED'
};
exports.MapVetoAction = exports.$Enums.MapVetoAction = {
exports.MapVoteAction = exports.$Enums.MapVoteAction = {
BAN: 'BAN',
PICK: 'PICK',
DECIDER: 'DECIDER'
@ -350,8 +352,8 @@ exports.Prisma.ModelName = {
Schedule: 'Schedule',
DemoFile: 'DemoFile',
ServerRequest: 'ServerRequest',
MapVeto: 'MapVeto',
MapVetoStep: 'MapVetoStep'
MapVote: 'MapVote',
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",
"types": "index.d.ts",
"browser": "index-browser.js",
@ -145,6 +145,6 @@
},
"./*": "./*"
},
"version": "6.13.0",
"version": "6.14.0",
"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 = {
type: 'compacted';
plan: {};
plan: QueryPlanNode;
arguments: Record<string, {}>[];
nestedSelection: string[];
keys: string[];
@ -376,6 +376,19 @@ declare type DatamodelEnum = ReadonlyDeep_2<{
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 = {
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 const denylist: readonly ["$connect", "$disconnect", "$on", "$transaction", "$use", "$extends"];
declare const denylist: readonly ["$connect", "$disconnect", "$on", "$transaction", "$extends"];
declare type Deprecation = ReadonlyDeep_2<{
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;
declare type Error_2 = {
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 Error_2 = MappedError & {
originalCode?: string;
originalMessage?: 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;
@ -1314,7 +1222,6 @@ declare type ExtendedSpanOptions = SpanOptions & {
/** The name of the span */
name: string;
internal?: boolean;
middleware?: boolean;
/** Whether it propagates context (?=true) */
active?: boolean;
/** The context to append the span to */
@ -1452,12 +1359,36 @@ declare type FieldDefault = ReadonlyDeep_2<{
declare type FieldDefaultScalar = string | boolean | number;
declare type FieldInitializer = {
type: 'value';
value: PrismaValue;
} | {
type: 'lastInsertId';
};
declare type FieldKind = 'scalar' | 'object' | 'enum' | 'unsupported';
declare type FieldLocation = 'scalar' | 'inputObjectTypes' | 'outputObjectTypes' | 'enumTypes' | 'fieldRefTypes';
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
*/
@ -1483,6 +1414,21 @@ export declare interface Fn<Params = unknown, Returns = unknown> {
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 {
name: string;
output: EnvValue | null;
@ -1576,7 +1522,6 @@ export declare function getPrismaClient(config: GetPrismaClientConfig): {
_clientVersion: string;
_errorFormat: ErrorFormat;
_tracingHelper: TracingHelper;
_middlewares: MiddlewareHandler<QueryMiddleware>;
_previewFeatures: string[];
_activeProvider: string;
_globalOmit?: GlobalOmitOptions | undefined;
@ -1591,11 +1536,6 @@ export declare function getPrismaClient(config: GetPrismaClientConfig): {
*/
_appliedParent: any;
_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;
$connect(): Promise<void>;
/**
@ -1875,6 +1815,14 @@ declare type IndexField = ReadonlyDeep_2<{
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.
* 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;
declare type JoinExpression = {
child: QueryPlanNode;
on: [left: string, right: string][];
parentField: string;
isRelationUnique: boolean;
};
export declare type JsArgs = {
select?: 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>;
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<{
modelOperations: ModelMapping[];
otherOperations: {
@ -2289,14 +2354,6 @@ declare type MiddlewareArgsMapper<RequestArgs, MiddlewareArgs> = {
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<{
name: string;
dbName: string | null;
@ -2378,7 +2435,7 @@ export declare type ModelQueryOptionsCbArgs = {
declare type MultiBatchResponse = {
type: 'multi';
plans: object[];
plans: QueryPlanNode[];
};
export declare type NameArgs = {
@ -2496,6 +2553,12 @@ declare type OutputType = ReadonlyDeep_2<{
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 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 }
declare interface PlaceholderFormat {
prefix: string;
hasNumbering: boolean;
}
declare type PrimaryKey = ReadonlyDeep_2<{
name: string | null;
fields: string[];
@ -2696,6 +2764,66 @@ declare type PrismaPromiseInteractiveTransaction<PayloadType = unknown> = {
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;
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 QueryMiddleware = (params: QueryMiddlewareParams, next: (params: QueryMiddlewareParams) => Promise<unknown>) => Promise<unknown>;
declare type QueryMiddlewareParams = {
/** The model this is executed on */
model?: string;
@ -2859,6 +2985,130 @@ declare type QueryOutput = ReadonlyDeep_2<{
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.
*/
@ -3047,6 +3297,19 @@ export declare type ResultFieldDefinition = {
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 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<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")
confirmedSchedules Schedule[] @relation("ConfirmedSchedules")
mapVetoChoices MapVetoStep[] @relation("VetoStepChooser")
mapVoteChoices MapVoteStep[] @relation("VoteStepChooser")
}
model Team {
@ -68,7 +68,7 @@ model Team {
schedulesAsTeamA Schedule[] @relation("ScheduleTeamA")
schedulesAsTeamB Schedule[] @relation("ScheduleTeamB")
mapVetoSteps MapVetoStep[] @relation("VetoStepTeam")
mapVoteSteps MapVoteStep[] @relation("VoteStepTeam")
}
model TeamInvite {
@ -138,7 +138,7 @@ model Match {
bestOf Int @default(3) // 1 | 3 | 5 app-seitig validieren
matchDate DateTime? // geplante Startzeit (separat von demoDate)
mapVeto MapVeto? // 1:1 Map-Vote-Status
mapVote MapVote?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -297,51 +297,49 @@ model ServerRequest {
// 🗺️ Map-Vote
// ──────────────────────────────────────────────
enum MapVetoAction {
enum MapVoteAction {
BAN
PICK
DECIDER
}
model MapVeto {
model MapVote {
id String @id @default(uuid())
matchId String @unique
match Match @relation(fields: [matchId], references: [id])
// Basiszustand
bestOf Int @default(3)
mapPool String[] // z.B. ["de_inferno","de_mirage",...]
currentIdx Int @default(0)
locked Boolean @default(false)
bestOf Int @default(3)
mapPool String[]
currentIdx Int @default(0)
locked Boolean @default(false)
opensAt DateTime?
// Optional: serverseitig speichern, statt im UI zu berechnen
opensAt DateTime?
adminEditingBy String?
adminEditingSince DateTime?
steps MapVetoStep[]
steps MapVoteStep[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MapVetoStep {
model MapVoteStep {
id String @id @default(uuid())
vetoId String
voteId String
order Int
action MapVetoAction
action MapVoteAction
// Team, das am Zug ist (kann bei DECIDER null sein)
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?
chosenAt DateTime?
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([chosenBy])
}

View File

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