updated mapvote
This commit is contained in:
parent
6caf57d282
commit
a832abff2e
@ -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)
|
||||
|
||||
// Optional: serverseitig speichern, statt im UI zu berechnen
|
||||
opensAt DateTime?
|
||||
|
||||
steps MapVetoStep[]
|
||||
adminEditingBy String?
|
||||
adminEditingSince DateTime?
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
|
||||
BIN
public/assets/img/maps/de_nuke/1.jpg
Normal file
BIN
public/assets/img/maps/de_nuke/1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@ -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,
|
||||
|
||||
@ -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 } })
|
||||
|
||||
191
src/app/api/matches/[matchId]/mapvote/admin-edit/route.ts
Normal file
191
src/app/api/matches/[matchId]/mapvote/admin-edit/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
// ---- 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 })
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
@ -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,49 +325,121 @@ 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={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')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{adminEditMode ? 'Bearbeiten: AN' : 'Bearbeiten'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-3"
|
||||
className="ml-2"
|
||||
title="Map-Vote zurücksetzen"
|
||||
onClick={async () => {
|
||||
if (!confirm('Map-Vote wirklich zurücksetzen? Alle bisherigen Picks/Bans gehen verloren.')) return
|
||||
@ -357,42 +450,45 @@ export default function MapVetoPanel({ match }: Props) {
|
||||
alert(j.message ?? 'Reset fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
// SSE feuert ohnehin; zusätzlich lokal nachladen:
|
||||
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
|
||||
{currentStep?.teamId === match.teamA?.id ? match.teamA.name : match.teamB.name}
|
||||
(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>
|
||||
)}
|
||||
</>
|
||||
@ -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]">
|
||||
{/* 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>
|
||||
<span className="opacity-90">
|
||||
<PremierRankBadge rank={rank ?? 0} />
|
||||
</span>
|
||||
</div>
|
||||
{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>
|
||||
)
|
||||
@ -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() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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
50
src/app/types/mapvote.ts
Normal 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
|
||||
}
|
||||
@ -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
@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
3260
src/generated/prisma/index.d.ts
vendored
3260
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -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
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
561
src/generated/prisma/runtime/library.d.ts
vendored
561
src/generated/prisma/runtime/library.d.ts
vendored
@ -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
56
src/generated/prisma/runtime/react-native.js
vendored
56
src/generated/prisma/runtime/react-native.js
vendored
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
@ -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",...]
|
||||
mapPool String[]
|
||||
currentIdx Int @default(0)
|
||||
locked Boolean @default(false)
|
||||
|
||||
// Optional: serverseitig speichern, statt im UI zu berechnen
|
||||
opensAt DateTime?
|
||||
|
||||
steps MapVetoStep[]
|
||||
adminEditingBy String?
|
||||
adminEditingSince DateTime?
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user