updated
This commit is contained in:
parent
8f88be26ce
commit
c692cefb22
@ -348,26 +348,10 @@ function uniq<T>(arr: T[]) {
|
||||
}
|
||||
|
||||
function collectParticipants(match: any): string[] {
|
||||
const fromMatchPlayers =
|
||||
(match.players ?? [])
|
||||
.map((mp: any) => mp?.user?.steamId)
|
||||
.filter(Boolean)
|
||||
|
||||
const fromTeamUsersA = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
|
||||
const fromTeamUsersB = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
|
||||
|
||||
const fromActiveA = Array.isArray(match.teamA?.activePlayers) ? match.teamA.activePlayers : []
|
||||
const fromActiveB = Array.isArray(match.teamB?.activePlayers) ? match.teamB.activePlayers : []
|
||||
|
||||
const leaderA = match.teamA?.leader?.steamId ? [match.teamA.leader.steamId] : []
|
||||
const leaderB = match.teamB?.leader?.steamId ? [match.teamB.leader.steamId] : []
|
||||
|
||||
return uniq<string>([
|
||||
...fromMatchPlayers,
|
||||
...fromTeamUsersA, ...fromTeamUsersB,
|
||||
...fromActiveA, ...fromActiveB,
|
||||
...leaderA, ...leaderB,
|
||||
])
|
||||
const a = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
|
||||
const b = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
|
||||
// ❗ keine Leader/activePlayers/match.players mehr – NUR echte Teamspieler
|
||||
return Array.from(new Set<string>([...a, ...b]))
|
||||
}
|
||||
|
||||
async function persistMatchPlayers(match: any) {
|
||||
@ -542,27 +526,11 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
|
||||
data: { exportedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
// ⬇️ OPTIONAL: cs2MatchId + exportedAt im Match speichern
|
||||
if (typeof json.matchid === 'number') {
|
||||
await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: { cs2MatchId: json.matchid, exportedAt: new Date() },
|
||||
})
|
||||
} else {
|
||||
await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: { exportedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[mapvote] Export fehlgeschlagen:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ---------- kleine Helfer für match-ready Payload ---------- */
|
||||
|
||||
function deriveChosenSteps(vote: any) {
|
||||
@ -649,8 +617,19 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
|
||||
|
||||
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 prisma.mapVote.update({
|
||||
where: { id: vote.id },
|
||||
data: {
|
||||
locked: true,
|
||||
adminEditingBy: null,
|
||||
adminEditingSince: null,
|
||||
},
|
||||
})
|
||||
|
||||
const updated = await prisma.mapVote.findUnique({
|
||||
where: { id: vote.id },
|
||||
include: { steps: true },
|
||||
})
|
||||
|
||||
await sendServerSSEMessage({
|
||||
type: 'map-vote-updated',
|
||||
@ -700,7 +679,13 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
|
||||
})
|
||||
await tx.mapVote.update({
|
||||
where: { id: vote.id },
|
||||
data : { currentIdx: vote.currentIdx + 1, locked: true },
|
||||
data : {
|
||||
currentIdx: vote.currentIdx + 1,
|
||||
locked: true,
|
||||
// ➜ Admin-Edit beenden
|
||||
adminEditingBy: null,
|
||||
adminEditingSince: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@ -796,7 +781,12 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
|
||||
|
||||
await tx.mapVote.update({
|
||||
where: { id: after.id },
|
||||
data : { currentIdx: idx, locked },
|
||||
data : {
|
||||
currentIdx: idx,
|
||||
locked,
|
||||
// ➜ Nur wenn jetzt abgeschlossen: Admin-Edit beenden
|
||||
...(locked ? { adminEditingBy: null, adminEditingSince: null } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -50,22 +50,10 @@ function voteOpensAt(base: Date, leadMinutes: number) {
|
||||
return new Date(base.getTime() - leadMinutes * 60_000)
|
||||
}
|
||||
|
||||
// Steps-Builder für BO1/BO3/BO5
|
||||
function buildSteps(bestOf: 1 | 3 | 5, firstTeamId: string | null, secondTeamId: string | null) {
|
||||
// Steps-Builder für BO3/BO5
|
||||
function buildSteps(bestOf: 3 | 5, firstTeamId: string | null, secondTeamId: string | null) {
|
||||
const A = firstTeamId
|
||||
const B = secondTeamId
|
||||
if (bestOf === 1) {
|
||||
// Klassischer BO1: 6x Ban (A/B abwechselnd), danach Decider
|
||||
return [
|
||||
{ order: 0, action: 'BAN', teamId: A },
|
||||
{ order: 1, action: 'BAN', teamId: B },
|
||||
{ order: 2, action: 'BAN', teamId: A },
|
||||
{ order: 3, action: 'BAN', teamId: B },
|
||||
{ order: 4, action: 'BAN', teamId: A },
|
||||
{ order: 5, action: 'BAN', teamId: B },
|
||||
{ order: 6, action: 'DECIDER', teamId: null },
|
||||
] as const
|
||||
}
|
||||
if (bestOf === 3) {
|
||||
// 2x Ban, 2x Pick, 2x Ban, Decider
|
||||
return [
|
||||
@ -108,7 +96,6 @@ export async function PUT(
|
||||
teamAId,
|
||||
teamBId,
|
||||
matchDate,
|
||||
map,
|
||||
voteLeadMinutes, // optional
|
||||
demoDate,
|
||||
bestOf: bestOfRaw, // <- NEU
|
||||
@ -116,7 +103,7 @@ export async function PUT(
|
||||
|
||||
// BestOf validieren (nur 1/3/5 zulassen)
|
||||
const bestOf =
|
||||
[1, 3, 5].includes(Number(bestOfRaw)) ? (Number(bestOfRaw) as 1 | 3 | 5) : undefined
|
||||
[3, 5].includes(Number(bestOfRaw)) ? (Number(bestOfRaw) as 3 | 5) : undefined
|
||||
|
||||
try {
|
||||
const match = await prisma.match.findUnique({
|
||||
@ -140,7 +127,6 @@ export async function PUT(
|
||||
const updateData: any = {}
|
||||
if (typeof title !== 'undefined') updateData.title = title
|
||||
if (typeof matchType === 'string') updateData.matchType = matchType
|
||||
if (typeof map !== 'undefined') updateData.map = map
|
||||
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
|
||||
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
|
||||
if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten
|
||||
@ -318,7 +304,6 @@ export async function PUT(
|
||||
teamBId: updated.teamBId,
|
||||
matchDate: updated.matchDate,
|
||||
demoDate: updated.demoDate,
|
||||
map: updated.map,
|
||||
bestOf: updated.bestOf,
|
||||
mapVote: updated.mapVote,
|
||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||
@ -327,3 +312,62 @@ export async function PUT(
|
||||
return NextResponse.json({ error: 'Failed to update match meta' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { matchId: string } }
|
||||
) {
|
||||
const id = params?.matchId
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing matchId in route params' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Falls Lesen nur für Admin/Leader erlaubt sein soll, Auth prüfen:
|
||||
const session = await getServerSession(authOptions(req))
|
||||
const me = session?.user
|
||||
if (!me?.steamId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const match = await prisma.match.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
teamA: { include: { leader: true } },
|
||||
teamB: { include: { leader: true } },
|
||||
mapVote: { include: { steps: true } }, // steps optional, wird oft praktisch
|
||||
},
|
||||
})
|
||||
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
|
||||
|
||||
// Optional: Zugriff nur für Admin/Leader erlauben (symmetrisch zu PUT)
|
||||
const isAdmin = !!me.isAdmin
|
||||
const isLeaderA = !!match.teamAId && match.teamA?.leader?.steamId === me.steamId
|
||||
const isLeaderB = !!match.teamBId && match.teamB?.leader?.steamId === me.steamId
|
||||
if (!isAdmin && !isLeaderA && !isLeaderB) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: match.id,
|
||||
title: match.title,
|
||||
matchType: match.matchType,
|
||||
teamAId: match.teamAId,
|
||||
teamBId: match.teamBId,
|
||||
matchDate: match.matchDate,
|
||||
demoDate: match.demoDate,
|
||||
bestOf: match.bestOf,
|
||||
mapVote: {
|
||||
id: match.mapVote?.id ?? null,
|
||||
leadMinutes: match.mapVote?.leadMinutes ?? 60,
|
||||
opensAt: match.mapVote?.opensAt ?? null,
|
||||
// steps nur mitgeben, wenn du sie brauchst:
|
||||
steps: match.mapVote?.steps ?? [],
|
||||
},
|
||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||
} catch (err) {
|
||||
console.error(`GET /matches/${id}/meta failed:`, err)
|
||||
return NextResponse.json({ error: 'Failed to load match meta' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
65
src/app/api/matches/current/route.ts
Normal file
65
src/app/api/matches/current/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/app/lib/prisma'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function uniq<T>(arr: T[]) { return Array.from(new Set(arr)) }
|
||||
|
||||
/**
|
||||
* „Aktuelles Match“:
|
||||
* - Du kannst hier deine eigene Definition hinterlegen (z.B. Match mit exportiertem json, oder demnächst startend).
|
||||
* - Beispiel: Das zuletzt exportierte (cs2MatchId gesetzt) oder das neueste mit matchDate in der Zukunft/Gegenwart.
|
||||
*/
|
||||
async function findCurrentMatch() {
|
||||
// 1) Bevorzugt: zuletzt exportiertes Match
|
||||
const exported = await prisma.match.findFirst({
|
||||
where: { cs2MatchId: { not: null } },
|
||||
orderBy: { exportedAt: 'desc' },
|
||||
include: {
|
||||
teamA: { include: { leader: true } },
|
||||
teamB: { include: { leader: true } },
|
||||
teamAUsers: true,
|
||||
teamBUsers: true,
|
||||
players: true, // MatchPlayer
|
||||
},
|
||||
})
|
||||
if (exported) return exported
|
||||
|
||||
// 2) Fallback: Nächstes geplantes / gestartetes Match
|
||||
const now = new Date()
|
||||
const upcoming = await prisma.match.findFirst({
|
||||
where: { matchDate: { gte: new Date(now.getTime() - 3 * 60 * 60 * 1000) } }, // ab 3h zurück
|
||||
orderBy: { matchDate: 'asc' },
|
||||
include: {
|
||||
teamA: { include: { leader: true } },
|
||||
teamB: { include: { leader: true } },
|
||||
teamAUsers: true,
|
||||
teamBUsers: true,
|
||||
players: true,
|
||||
},
|
||||
})
|
||||
return upcoming
|
||||
}
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
const match = await findCurrentMatch()
|
||||
if (!match) {
|
||||
return NextResponse.json({ matchId: null, steamIds: [], total: 0 }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
|
||||
// Roster bestimmen: bevorzugt MatchPlayer, sonst Team-User + Leader
|
||||
let steamIds: string[] = (match.players ?? []).map(mp => mp.steamId)
|
||||
if (steamIds.length === 0) {
|
||||
const fromA = (match.teamAUsers ?? []).map(u => u.steamId)
|
||||
const fromB = (match.teamBUsers ?? []).map(u => u.steamId)
|
||||
const leaderA = match.teamA?.leader?.steamId ? [match.teamA.leader.steamId] : []
|
||||
const leaderB = match.teamB?.leader?.steamId ? [match.teamB.leader.steamId] : []
|
||||
steamIds = uniq([...fromA, ...fromB, ...leaderA, ...leaderB].filter(Boolean))
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ matchId: match.id, steamIds, total: steamIds.length },
|
||||
{ headers: { 'Cache-Control': 'no-store' } }
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
// app/components/EditMatchMetaModal.tsx
|
||||
// /src/app/components/EditMatchMetaModal.tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Modal from '@/app/components/Modal'
|
||||
import Alert from '@/app/components/Alert'
|
||||
import Select from '@/app/components/Select'
|
||||
import LoadingSpinner from '@/app/components/LoadingSpinner' // ⬅️ NEU
|
||||
|
||||
type TeamOption = { id: string; name: string; logo?: string | null }
|
||||
|
||||
@ -18,10 +19,10 @@ type Props = {
|
||||
defaultTeamAName?: string | null
|
||||
defaultTeamBName?: string | null
|
||||
defaultDateISO?: string | null
|
||||
defaultMap?: string | null // bleibt im Typ für Kompatibilität, wird aber nicht mehr genutzt
|
||||
defaultMap?: string | null // nur aus Kompatibilitätsgründen noch vorhanden
|
||||
defaultVoteLeadMinutes?: number
|
||||
onSaved?: () => void
|
||||
defaultBestOf?: 1 | 3 | 5
|
||||
defaultBestOf?: 3 | 5
|
||||
}
|
||||
|
||||
export default function EditMatchMetaModal({
|
||||
@ -34,108 +35,159 @@ export default function EditMatchMetaModal({
|
||||
defaultTeamAName,
|
||||
defaultTeamBName,
|
||||
defaultDateISO,
|
||||
// defaultMap, // nicht mehr genutzt
|
||||
// defaultMap,
|
||||
defaultVoteLeadMinutes = 60,
|
||||
onSaved,
|
||||
defaultBestOf = 3,
|
||||
}: Props) {
|
||||
/* ───────── Utils ───────── */
|
||||
const normalizeBestOf = (bo: unknown): 3 | 5 => (Number(bo) === 5 ? 5 : 3)
|
||||
const toDatetimeLocal = (iso?: string | null) => {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
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())}`
|
||||
}
|
||||
|
||||
/* ───────── Local state ───────── */
|
||||
const [title, setTitle] = useState(defaultTitle ?? '')
|
||||
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
||||
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
||||
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
|
||||
|
||||
const [date, setDate] = useState<string>(() => {
|
||||
if (!defaultDateISO) return ''
|
||||
const d = new Date(defaultDateISO)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
})
|
||||
|
||||
// Nur noch BestOf editierbar
|
||||
const [bestOf, setBestOf] = useState<1 | 3 | 5>(defaultBestOf)
|
||||
const [date, setDate] = useState<string>(toDatetimeLocal(defaultDateISO))
|
||||
const [bestOf, setBestOf] = useState<3 | 5>(normalizeBestOf(defaultBestOf))
|
||||
|
||||
const [teams, setTeams] = useState<TeamOption[]>([])
|
||||
const [loadingTeams, setLoadingTeams] = useState(false)
|
||||
|
||||
const [loadingMeta, setLoadingMeta] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const openedOnceRef = useRef(false)
|
||||
// BO-Referenz aus /meta, um den Hinweis korrekt zu steuern
|
||||
const [metaBestOf, setMetaBestOf] = useState<3 | 5 | null>(null)
|
||||
|
||||
// Teams laden
|
||||
// Dedupe-Refs (StrictMode)
|
||||
const teamsFetchedRef = useRef(false)
|
||||
const metaFetchedRef = useRef(false)
|
||||
|
||||
/* ───────── Reset bei Schließen ───────── */
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
teamsFetchedRef.current = false
|
||||
metaFetchedRef.current = false
|
||||
setSaved(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [show])
|
||||
|
||||
/* ───────── Teams laden ───────── */
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
if (teamsFetchedRef.current) return
|
||||
|
||||
let alive = true
|
||||
setLoadingTeams(true)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||||
const data = res.ok ? await res.json() : []
|
||||
if (!alive) return
|
||||
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? [])
|
||||
setTeams((list ?? []).filter((t: any) => t?.id && t?.name))
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
if (!alive) return
|
||||
console.error('[EditMatchMetaModal] load teams failed:', e)
|
||||
setTeams([])
|
||||
} finally {
|
||||
setLoadingTeams(false)
|
||||
if (alive) {
|
||||
setLoadingTeams(false)
|
||||
teamsFetchedRef.current = true // Flag erst nach erfolgreichem Lauf
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
teamsFetchedRef.current = false
|
||||
}
|
||||
}, [show])
|
||||
|
||||
// Defaults beim Öffnen (einmal)
|
||||
/* ───────── Meta neu laden beim Öffnen ───────── */
|
||||
useEffect(() => {
|
||||
if (!show) { openedOnceRef.current = false; return }
|
||||
if (openedOnceRef.current) return
|
||||
openedOnceRef.current = true
|
||||
if (!show) return
|
||||
if (metaFetchedRef.current) return
|
||||
|
||||
setTitle(defaultTitle ?? '')
|
||||
setTeamAId(defaultTeamAId ?? '')
|
||||
setTeamBId(defaultTeamBId ?? '')
|
||||
setVoteLead(defaultVoteLeadMinutes)
|
||||
|
||||
if (defaultDateISO) {
|
||||
const d = new Date(defaultDateISO)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
setDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`)
|
||||
} else {
|
||||
setDate('')
|
||||
}
|
||||
|
||||
setBestOf(defaultBestOf ?? 3)
|
||||
setSaved(false)
|
||||
let alive = true
|
||||
setLoadingMeta(true)
|
||||
setError(null)
|
||||
}, [
|
||||
show,
|
||||
defaultTitle,
|
||||
defaultTeamAId,
|
||||
defaultTeamBId,
|
||||
defaultDateISO,
|
||||
defaultVoteLeadMinutes,
|
||||
defaultBestOf,
|
||||
])
|
||||
|
||||
// Optionen
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/matches/${matchId}/meta`, { cache: 'no-store' })
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}))
|
||||
throw new Error(j?.error || `Meta-Load failed (${res.status})`)
|
||||
}
|
||||
const j = await res.json()
|
||||
if (!alive) return
|
||||
|
||||
setTitle(j?.title ?? '')
|
||||
setTeamAId(j?.teamAId ?? '')
|
||||
setTeamBId(j?.teamBId ?? '')
|
||||
setDate(toDatetimeLocal(j?.matchDate ?? j?.demoDate ?? null))
|
||||
setVoteLead(
|
||||
Number.isFinite(Number(j?.mapVote?.leadMinutes)) ? Number(j.mapVote.leadMinutes) : 60
|
||||
)
|
||||
|
||||
const boFromMeta = normalizeBestOf(j?.bestOf)
|
||||
setBestOf(boFromMeta)
|
||||
setMetaBestOf(boFromMeta)
|
||||
setSaved(false)
|
||||
} catch (e: any) {
|
||||
if (!alive) return
|
||||
console.error('[EditMatchMetaModal] reload meta failed:', e)
|
||||
setError(e?.message || 'Konnte aktuelle Match-Metadaten nicht laden.')
|
||||
} finally {
|
||||
if (alive) {
|
||||
setLoadingMeta(false)
|
||||
metaFetchedRef.current = true // Flag erst nach Abschluss setzen
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
metaFetchedRef.current = false
|
||||
}
|
||||
}, [show, matchId])
|
||||
|
||||
/* ───────── Optionen für Selects ───────── */
|
||||
const teamOptionsA = useMemo(
|
||||
() => teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.name })),
|
||||
() => teams.filter((t) => t.id !== teamBId).map((t) => ({ value: t.id, label: t.name })),
|
||||
[teams, teamBId]
|
||||
)
|
||||
const teamOptionsB = useMemo(
|
||||
() => teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.name })),
|
||||
() => teams.filter((t) => t.id !== teamAId).map((t) => ({ value: t.id, label: t.name })),
|
||||
[teams, teamAId]
|
||||
)
|
||||
|
||||
// Hinweis-Flag: Best Of geändert?
|
||||
const defaultBestOfNormalized = (defaultBestOf ?? 3) as 1 | 3 | 5
|
||||
const bestOfChanged = bestOf !== defaultBestOfNormalized
|
||||
/* ───────── Hinweis-Flag nur vs. /meta ───────── */
|
||||
const showBoChangedHint = metaBestOf !== null && bestOf !== metaBestOf
|
||||
|
||||
// Validation
|
||||
/* ───────── Validation ───────── */
|
||||
const canSave = useMemo(() => {
|
||||
if (saving) return false
|
||||
if (saving || loadingMeta) return false
|
||||
if (!date) return false
|
||||
if (teamAId && teamBId && teamAId === teamBId) return false
|
||||
return true
|
||||
}, [saving, date, teamAId, teamBId])
|
||||
}, [saving, loadingMeta, date, teamAId, teamBId])
|
||||
|
||||
// Save → nur bestOf wird (zusätzlich) übertragen; Server resettet MapVote bei Änderung
|
||||
/* ───────── Save ───────── */
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
@ -146,7 +198,7 @@ export default function EditMatchMetaModal({
|
||||
teamBId: teamBId || null,
|
||||
matchDate: date ? new Date(date).toISOString() : null,
|
||||
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
|
||||
bestOf, // <- wichtig
|
||||
bestOf,
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/matches/${matchId}/meta`, {
|
||||
@ -170,8 +222,11 @@ export default function EditMatchMetaModal({
|
||||
}
|
||||
}
|
||||
|
||||
const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …')
|
||||
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …')
|
||||
const teamAPlaceholder = loadingTeams ? 'Teams laden …' : defaultTeamAName || 'Team A wählen …'
|
||||
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : defaultTeamBName || 'Team B wählen …'
|
||||
|
||||
// ⬇️ Neu: Wenn Meta lädt, zeigen wir ausschließlich den Spinner statt der Form
|
||||
const showOnlySpinner = loadingMeta && show
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -180,109 +235,125 @@ export default function EditMatchMetaModal({
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
onSave={handleSave}
|
||||
closeButtonTitle={saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'}
|
||||
closeButtonTitle={
|
||||
saved ? '✓ Gespeichert' : saving ? 'Speichern …' : loadingMeta ? 'Lädt …' : 'Speichern'
|
||||
}
|
||||
closeButtonColor={saved ? 'green' : 'blue'}
|
||||
disableSave={!canSave}
|
||||
maxWidth="sm:max-w-2xl"
|
||||
>
|
||||
{error && (
|
||||
{error && !loadingMeta && (
|
||||
<Alert type="soft" color="danger" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Titel */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Titel</label>
|
||||
<input
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="z.B. Scrim vs. XYZ"
|
||||
/>
|
||||
{showOnlySpinner ? (
|
||||
<div className="py-12 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
{/* Team A */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Team A</label>
|
||||
<Select
|
||||
options={teamOptionsA}
|
||||
value={teamAId}
|
||||
onChange={setTeamAId}
|
||||
placeholder={teamAPlaceholder}
|
||||
dropDirection="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team B */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Team B</label>
|
||||
<Select
|
||||
options={teamOptionsB}
|
||||
value={teamBId}
|
||||
onChange={setTeamBId}
|
||||
placeholder={teamBPlaceholder}
|
||||
dropDirection="auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Datum/Uhrzeit */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Datum & Uhrzeit</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vote-Lead */}
|
||||
<div>
|
||||
<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={voteLead}
|
||||
onChange={e => setVoteLead(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nur noch Best Of */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Modus (Best of)</label>
|
||||
<div className="flex gap-2">
|
||||
{[1, 3, 5].map(bo => (
|
||||
<button
|
||||
key={bo}
|
||||
type="button"
|
||||
onClick={() => setBestOf(bo as 1|3|5)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm border
|
||||
${bestOf === bo
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
|
||||
>
|
||||
BO{bo}
|
||||
</button>
|
||||
))}
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Titel */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Titel</label>
|
||||
<input
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Scrim vs. XYZ"
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{bestOfChanged && (
|
||||
<Alert type="soft" color="warning" className="mt-2">
|
||||
Du hast den Modus von <b>BO{defaultBestOfNormalized}</b> auf <b>BO{bestOf}</b> geändert.
|
||||
Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden verworfen).
|
||||
</Alert>
|
||||
)}
|
||||
{/* Team A */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Team A</label>
|
||||
<Select
|
||||
options={teamOptionsA}
|
||||
value={teamAId}
|
||||
onChange={setTeamAId}
|
||||
placeholder={teamAPlaceholder}
|
||||
dropDirection="auto"
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Team B */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Team B</label>
|
||||
<Select
|
||||
options={teamOptionsB}
|
||||
value={teamBId}
|
||||
onChange={setTeamBId}
|
||||
placeholder={teamBPlaceholder}
|
||||
dropDirection="auto"
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Datum/Uhrzeit */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Datum & Uhrzeit</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vote-Lead */}
|
||||
<div>
|
||||
<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={voteLead}
|
||||
onChange={(e) => setVoteLead(Number(e.target.value))}
|
||||
disabled={loadingMeta}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Best Of */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">Modus (Best of)</label>
|
||||
<div className="flex gap-2">
|
||||
{[3, 5].map((bo) => (
|
||||
<button
|
||||
key={bo}
|
||||
type="button"
|
||||
onClick={() => setBestOf(bo as 3 | 5)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm border ${
|
||||
bestOf === bo
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'
|
||||
}`}
|
||||
disabled={loadingMeta}
|
||||
>
|
||||
BO{bo}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{metaBestOf !== null && bestOf !== metaBestOf && (
|
||||
<Alert type="soft" color="warning" className="mt-2">
|
||||
Du hast den Modus von <b>BO{metaBestOf}</b> auf <b>BO{bestOf}</b> geändert.
|
||||
Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden
|
||||
verworfen).
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -134,6 +134,9 @@ export default function MapVoteBanner({
|
||||
const isLocked = !!state?.locked
|
||||
const isVotingOpen = isOpen && !isLocked
|
||||
|
||||
const isEnded = !!state?.locked
|
||||
const isLive = isOpen && !isEnded
|
||||
|
||||
const current = state?.steps?.[state?.currentIndex ?? 0]
|
||||
const whoIsUp = current?.teamId
|
||||
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
|
||||
@ -152,24 +155,24 @@ export default function MapVoteBanner({
|
||||
|
||||
const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`)
|
||||
|
||||
// Farblogik: locked → grün, offen → gelb, noch geschlossen → neutral
|
||||
const ringClass = isLocked
|
||||
? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg'
|
||||
: isVotingOpen
|
||||
? 'ring-1 ring-yellow-500/20 hover:ring-yellow-500/35 hover:shadow-lg'
|
||||
// Vorher: locked -> grün, live -> gelb
|
||||
// Neu: live -> grün, locked -> grau
|
||||
const ringClass = isLive
|
||||
? 'ring-1 ring-green-500/20 hover:ring-green-500/35 hover:shadow-lg'
|
||||
: isEnded
|
||||
? 'ring-1 ring-neutral-500/15 hover:ring-neutral-500/25 hover:shadow-md'
|
||||
: 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md'
|
||||
|
||||
const bubbleClass = isLocked
|
||||
const bubbleClass = isLive
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200'
|
||||
: isVotingOpen
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100'
|
||||
: isEnded
|
||||
? 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700/40 dark:text-neutral-200'
|
||||
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700/40 dark:text-neutral-200'
|
||||
|
||||
const gradientClass = isLocked
|
||||
const gradientClass = isLive
|
||||
? 'mapVoteGradient--green'
|
||||
: isVotingOpen
|
||||
? 'mapVoteGradient--yellow'
|
||||
: 'mapVoteGradient--none'
|
||||
: 'mapVoteGradient--none' // beendet und noch-nicht-offen: kein Effekt
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -199,9 +202,9 @@ export default function MapVoteBanner({
|
||||
<div className="font-medium text-gray-900 dark:text-neutral-100">Map-Vote</div>
|
||||
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
|
||||
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
|
||||
{state?.locked
|
||||
{isEnded
|
||||
? ' • Auswahl fixiert'
|
||||
: isVotingOpen
|
||||
: isLive
|
||||
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
|
||||
: ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`}
|
||||
</div>
|
||||
@ -210,19 +213,17 @@ export default function MapVoteBanner({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{isEnded ? (
|
||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200">
|
||||
Voting abgeschlossen
|
||||
</span>
|
||||
) : isVotingOpen ? (
|
||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100">
|
||||
) : isLive ? (
|
||||
<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-100">
|
||||
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-100 text-neutral-800 dark:bg-neutral-700/40 dark:text-neutral-200"
|
||||
suppressHydrationWarning>
|
||||
Öffnet in {mounted ? formatCountdown(msToOpen) : '–:–:–'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -880,6 +880,15 @@ export default function MapVotePanel({ match }: Props) {
|
||||
? chosenSteps.slice(0, idx).filter(s => s.action === 'pick').length + 1
|
||||
: null
|
||||
|
||||
// ➜ NEU: Für den Decider ebenfalls eine Nummer anzeigen (Slot-Nummer)
|
||||
const displayNumber =
|
||||
action === 'pick'
|
||||
? pickNumber
|
||||
: action === 'decider'
|
||||
? idx + 1
|
||||
: null
|
||||
|
||||
|
||||
const pickedByLeft = (action === 'pick' || action === 'decider') && pickTeamId === leftTeamId
|
||||
const pickedByRight = (action === 'pick' || action === 'decider') && pickTeamId === rightTeamId
|
||||
const cornerLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null
|
||||
@ -924,15 +933,21 @@ export default function MapVotePanel({ match }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pick-Nummer oben links (nur für Picks) */}
|
||||
{typeof pickNumber === 'number' && (
|
||||
{/* Nummer oben links (für Pick UND Decider) */}
|
||||
{typeof displayNumber === 'number' && (
|
||||
<span
|
||||
className="absolute left-2 top-2 z-30 w-6 h-6 rounded-full
|
||||
bg-white/95 text-neutral-900 text-xs md:text-sm font-bold
|
||||
flex items-center justify-center shadow"
|
||||
title={`Pick ${pickNumber}`}
|
||||
bg-white/95 text-neutral-900 text-xs md:text-sm font-bold
|
||||
flex items-center justify-center shadow"
|
||||
title={
|
||||
action === 'pick'
|
||||
? `Pick ${displayNumber}`
|
||||
: action === 'decider'
|
||||
? `Decider (Map ${displayNumber})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{pickNumber}
|
||||
{displayNumber}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@ -562,9 +562,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
||||
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
||||
defaultMap={match.map ?? null}
|
||||
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
|
||||
// ⬇️ neu:
|
||||
defaultBestOf={(match.bestOf as 1 | 3 | 5) ?? 3}
|
||||
defaultSeries={extractSeriesMaps(match)} // Array mit map-Keys (kann '' enthalten)
|
||||
defaultBestOf={Number(match.bestOf) === 5 ? 5 : 3}
|
||||
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -7,7 +7,6 @@ import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
import { MAP_OPTIONS } from '../lib/mapOptions'
|
||||
import type { MapOption } from '../lib/mapOptions'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@ -42,14 +41,23 @@ export default function MatchReadyOverlay({
|
||||
connectHref
|
||||
}: Props) {
|
||||
const { data: session } = useSession()
|
||||
const mySteamId = session?.user?.steamId
|
||||
const mySteamId = session?.user?.steamId ? String(session.user.steamId) : null
|
||||
const { lastEvent } = useSSEStore()
|
||||
|
||||
// --- Team-Guard: nur Team A/B
|
||||
const [allowedIds, setAllowedIds] = useState<string[] | null>(null)
|
||||
const iAmAllowed = useMemo(() => {
|
||||
if (!mySteamId) return false
|
||||
if (allowedIds === null) return true // bis die Liste da ist, nicht blocken
|
||||
return allowedIds.includes(mySteamId)
|
||||
}, [allowedIds, mySteamId])
|
||||
|
||||
// Verbindungslink
|
||||
const ENV_CONNECT_HREF = process.env.NEXT_PUBLIC_CONNECT_HREF
|
||||
const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000'
|
||||
const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF
|
||||
|
||||
// Timer
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [startedAt] = useState(() => Date.now())
|
||||
const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt])
|
||||
@ -59,30 +67,29 @@ export default function MatchReadyOverlay({
|
||||
// UI-States
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [finished, setFinished] = useState(false)
|
||||
const [showWaitHint, setShowWaitHint] = useState(false) // ⬅️ nutzt du unten zum Ausblenden des Countdowns
|
||||
const [showWaitHint, setShowWaitHint] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const isVisible = open || accepted || showWaitHint
|
||||
const [showConnectHelp, setShowConnectHelp] = useState(false)
|
||||
const [showBackdrop, setShowBackdrop] = useState(false)
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
|
||||
// Ready-Listen-Status
|
||||
// Sichtbarkeit (ohne early return!)
|
||||
const isVisibleBase = open || accepted || showWaitHint
|
||||
const shouldRender = Boolean(isVisibleBase && iAmAllowed)
|
||||
|
||||
// Ready-Status
|
||||
type Participant = { steamId: string; name: string; avatar: string; team: 'A' | 'B' | null }
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
const [readyMap, setReadyMap] = useState<Record<string, string>>({})
|
||||
const [total, setTotal] = useState(0)
|
||||
const [countReady, setCountReady] = useState(0)
|
||||
|
||||
// Presence-Map (SSE)
|
||||
// Presence (SSE)
|
||||
const [statusMap, setStatusMap] = useState<Record<string, Presence>>({})
|
||||
|
||||
const prevCountReadyRef = useRef<number>(0)
|
||||
const ignoreNextIncreaseRef = useRef(false)
|
||||
|
||||
// ----- AUDIO -----
|
||||
const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const audioStartedRef = useRef(false)
|
||||
|
||||
const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } }
|
||||
|
||||
const ensureAudioUnlocked = async () => {
|
||||
@ -127,33 +134,35 @@ export default function MatchReadyOverlay({
|
||||
}
|
||||
try { onTimeout?.() } catch {}
|
||||
}
|
||||
// mini delay für UI Feedback
|
||||
setTimeout(doConnect, 200)
|
||||
}, [finished, effectiveConnectHref, onTimeout])
|
||||
|
||||
// NUR nach Acceptance laden/aktualisieren
|
||||
// Ready-API nur nach Accept
|
||||
const loadReady = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' })
|
||||
if (!r.ok) return
|
||||
const j = await r.json()
|
||||
setParticipants(j.participants ?? [])
|
||||
const parts: Participant[] = j.participants ?? []
|
||||
setParticipants(parts)
|
||||
setReadyMap(j.ready ?? {})
|
||||
setTotal(j.total ?? 0)
|
||||
setCountReady(j.countReady ?? 0)
|
||||
|
||||
// Team-Guard füttern
|
||||
const ids = parts.map(p => String(p.steamId)).filter(Boolean)
|
||||
if (ids.length) setAllowedIds(ids)
|
||||
} catch {}
|
||||
}, [matchId])
|
||||
|
||||
// ---- Accept-Handling ----
|
||||
// Accept
|
||||
const postingRef = useRef(false)
|
||||
|
||||
const onAcceptClick = async () => {
|
||||
if (postingRef.current) return
|
||||
postingRef.current = true
|
||||
try {
|
||||
stopBeeps()
|
||||
playMenuAccept()
|
||||
|
||||
const res = await fetch(`/api/matches/${matchId}/ready`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Ready-Accept': '1' },
|
||||
@ -161,7 +170,6 @@ export default function MatchReadyOverlay({
|
||||
body: JSON.stringify({ intent: 'accept' }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
|
||||
setAccepted(true)
|
||||
try { await onAccept() } catch {}
|
||||
await loadReady()
|
||||
@ -170,8 +178,7 @@ export default function MatchReadyOverlay({
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ⬇️ NEU: nach 30s „Es lädt nicht?“ anzeigen
|
||||
// „Es lädt nicht?“ nach 30s
|
||||
useEffect(() => {
|
||||
let id: number | null = null
|
||||
if (connecting) {
|
||||
@ -181,17 +188,15 @@ export default function MatchReadyOverlay({
|
||||
return () => { if (id) window.clearTimeout(id) }
|
||||
}, [connecting])
|
||||
|
||||
// Backdrop zuerst faden, dann Content
|
||||
// Backdrop → Content
|
||||
useEffect(() => {
|
||||
if (!isVisible) { setShowBackdrop(false); setShowContent(false); return }
|
||||
if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return }
|
||||
setShowBackdrop(true)
|
||||
const id = setTimeout(() => setShowContent(true), 300) // vorher: 2000
|
||||
const id = setTimeout(() => setShowContent(true), 300)
|
||||
return () => clearTimeout(id)
|
||||
}, [isVisible])
|
||||
}, [shouldRender])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
// Nach Accept ein kurzer Refresh
|
||||
// Nach Accept kurzer Refresh
|
||||
useEffect(() => {
|
||||
if (!accepted) return
|
||||
const id = setTimeout(loadReady, 250)
|
||||
@ -199,12 +204,19 @@ export default function MatchReadyOverlay({
|
||||
}, [accepted, loadReady])
|
||||
|
||||
// SSE
|
||||
const { lastEvent: le } = useSSEStore()
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
||||
const payload = (lastEvent as any).payload?.payload ?? (lastEvent as any).payload ?? lastEvent
|
||||
|
||||
// participants aus Event übernehmen (falls geschickt)
|
||||
const payloadParticipants: string[] | undefined = Array.isArray(payload?.participants)
|
||||
? payload.participants.map((sid: any) => String(sid)).filter(Boolean)
|
||||
: undefined
|
||||
if (payloadParticipants && payloadParticipants.length) {
|
||||
setAllowedIds(payloadParticipants)
|
||||
}
|
||||
|
||||
if (type === 'ready-updated' && payload?.matchId === matchId) {
|
||||
if (accepted) {
|
||||
const otherSteamId = payload?.steamId as string | undefined
|
||||
@ -223,15 +235,15 @@ export default function MatchReadyOverlay({
|
||||
}
|
||||
}, [accepted, lastEvent, matchId, mySteamId, loadReady])
|
||||
|
||||
// ----- simple mount animation flags -----
|
||||
// Mount-Animation
|
||||
const [fadeIn, setFadeIn] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!isVisible) { setFadeIn(false); return }
|
||||
if (!shouldRender) { setFadeIn(false); return }
|
||||
const id = requestAnimationFrame(() => setFadeIn(true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [isVisible])
|
||||
}, [shouldRender])
|
||||
|
||||
// ----- motion layer (video/gif) -----
|
||||
// Motion-Layer
|
||||
const prefersReducedMotion = useMemo(
|
||||
() => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
||||
[]
|
||||
@ -240,7 +252,7 @@ export default function MatchReadyOverlay({
|
||||
const [useGif, setUseGif] = useState<boolean>(() => !!forceGif || !!prefersReducedMotion)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return
|
||||
if (!shouldRender) return
|
||||
if (forceGif || prefersReducedMotion) { setUseGif(true); return }
|
||||
const tryPlay = async () => {
|
||||
const v = videoRef.current
|
||||
@ -259,22 +271,21 @@ export default function MatchReadyOverlay({
|
||||
}
|
||||
const id = setTimeout(tryPlay, 0)
|
||||
return () => clearTimeout(id)
|
||||
}, [isVisible, forceGif, prefersReducedMotion])
|
||||
}, [shouldRender, forceGif, prefersReducedMotion])
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) return
|
||||
if (shouldRender) return
|
||||
const v = videoRef.current
|
||||
if (v) {
|
||||
try { v.pause() } catch {}
|
||||
v.removeAttribute('src')
|
||||
while (v.firstChild) v.removeChild(v.firstChild)
|
||||
}
|
||||
}, [isVisible])
|
||||
}, [shouldRender])
|
||||
|
||||
// ----- AUDIO: Beeps starten/stoppen -----
|
||||
// Beeps erst starten, wenn der Content sichtbar ist
|
||||
// AUDIO: Beeps starten/stoppen
|
||||
useEffect(() => {
|
||||
if (!showContent) { // vorher: if (!isVisible)
|
||||
if (!showContent) {
|
||||
stopBeeps()
|
||||
audioStartedRef.current = false
|
||||
return
|
||||
@ -300,53 +311,53 @@ export default function MatchReadyOverlay({
|
||||
})()
|
||||
|
||||
return () => { cleanup(); stopBeeps() }
|
||||
}, [showContent]) // vorher: [isVisible]
|
||||
}, [showContent])
|
||||
|
||||
// ⏩ Sofort verbinden, wenn alle bereit sind
|
||||
// Auto-Connect wenn alle bereit
|
||||
useEffect(() => {
|
||||
if (!isVisible) return
|
||||
if (!shouldRender) return
|
||||
if (total > 0 && countReady >= total && !finished) {
|
||||
startConnectingNow()
|
||||
}
|
||||
}, [isVisible, total, countReady, finished, startConnectingNow])
|
||||
}, [shouldRender, total, countReady, finished, startConnectingNow])
|
||||
|
||||
// ----- countdown / timeout -----
|
||||
// Countdown
|
||||
const rafRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!isVisible) return
|
||||
if (!shouldRender) return
|
||||
const step = () => {
|
||||
const t = Date.now()
|
||||
setNow(t)
|
||||
if (effectiveDeadline - t <= 0 && !finished) {
|
||||
if (accepted) {
|
||||
startConnectingNow()
|
||||
} else {
|
||||
stopBeeps()
|
||||
setFinished(true)
|
||||
setShowWaitHint(true)
|
||||
if (accepted) {
|
||||
startConnectingNow()
|
||||
} else {
|
||||
stopBeeps()
|
||||
setFinished(true)
|
||||
setShowWaitHint(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
||||
}, [isVisible, effectiveDeadline, accepted, finished, connectHref, onTimeout])
|
||||
}, [shouldRender, effectiveDeadline, accepted, finished, onTimeout, startConnectingNow])
|
||||
|
||||
// 🔎 Map-Icon aus MAP_OPTIONS ermitteln
|
||||
// Map-Icon
|
||||
const mapIconUrl = useMemo(() => {
|
||||
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase()
|
||||
const keyFromBg = /\/maps\/([^/]+)\//.exec(mapBg ?? '')?.[1]
|
||||
|
||||
const byKey = keyFromBg ? MAP_OPTIONS.find(o => o.key === keyFromBg) : undefined
|
||||
const byLabel = mapLabel ? MAP_OPTIONS.find(o => norm(o.label) === norm(mapLabel)) : undefined
|
||||
const byImage = mapBg ? MAP_OPTIONS.find(o => o.images.includes(mapBg)) : undefined
|
||||
|
||||
const opt = byKey ?? byLabel ?? byImage
|
||||
return opt?.icon ?? '/assets/img/mapicons/map_icon_lobby_mapveto.svg'
|
||||
}, [mapBg, mapLabel])
|
||||
|
||||
// --- UI Helpers ---
|
||||
// ---------- RENDER ----------
|
||||
if (!shouldRender) return null
|
||||
|
||||
const ReadyRow = () => (
|
||||
<div className="absolute left-0 right-0 bottom-0 px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1 justify-center">
|
||||
@ -402,7 +413,7 @@ export default function MatchReadyOverlay({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1000]">
|
||||
{/* Backdrop: 2s-Fade */}
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-0 bg-black/60 transition-opacity duration-[300ms] ease-out',
|
||||
@ -410,7 +421,7 @@ export default function MatchReadyOverlay({
|
||||
].join(' ')}
|
||||
/>
|
||||
|
||||
{/* Content erst nach Backdrop-Delay */}
|
||||
{/* Content */}
|
||||
{showContent && (
|
||||
<div
|
||||
className={[
|
||||
@ -428,7 +439,7 @@ export default function MatchReadyOverlay({
|
||||
className="absolute inset-0 w-full h-full object-cover brightness-90"
|
||||
/>
|
||||
|
||||
{/* Deko-Layer (Gif/Video) */}
|
||||
{/* Motion-Layer */}
|
||||
{useGif ? (
|
||||
<div className="absolute inset-0 opacity-50 pointer-events-none">
|
||||
<img
|
||||
@ -454,7 +465,7 @@ export default function MatchReadyOverlay({
|
||||
</video>
|
||||
)}
|
||||
|
||||
{/* 🔽 NEU: dunkler Gradient wie bei „Gewählte Maps“ */}
|
||||
{/* Gradient */}
|
||||
<div className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
|
||||
|
||||
{/* Inhalt */}
|
||||
@ -498,13 +509,11 @@ export default function MatchReadyOverlay({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Countdown oder Verbinde-Status */}
|
||||
{/* 🔽 NEU: Countdown ausblenden, wenn der Warte-Hinweis gezeigt wird */}
|
||||
{/* Countdown / Verbinde-Status */}
|
||||
{!showWaitHint && (
|
||||
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
|
||||
{connecting ? (
|
||||
showConnectHelp ? (
|
||||
// ⬇️ NEU: nach 30s
|
||||
<span className="inline-flex items-center gap-3 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10">
|
||||
<span className="text-[#f8e08e] font-semibold">Es lädt nicht?</span>
|
||||
<a
|
||||
@ -515,7 +524,6 @@ export default function MatchReadyOverlay({
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
// bisheriges „Verbinde…“
|
||||
<span
|
||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10"
|
||||
role="status"
|
||||
|
||||
@ -9,6 +9,7 @@ import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
|
||||
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore'
|
||||
import TelemetryBanner from './TelemetryBanner'
|
||||
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
|
||||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||
|
||||
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
||||
const h = (host ?? '').trim() || '127.0.0.1'
|
||||
@ -46,7 +47,6 @@ function labelForMap(key?: string | null): string {
|
||||
const k = String(key).toLowerCase()
|
||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === k)
|
||||
if (opt?.label) return opt.label
|
||||
// Fallback: "de_dust2" -> "Dust 2"
|
||||
let s = k.replace(/^(de|cs)_/, '').replace(/_/g, ' ').replace(/(\d)/g, ' $1').replace(/\s+/g, ' ').trim()
|
||||
s = s.split(' ').map(w => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ')
|
||||
return s
|
||||
@ -84,8 +84,10 @@ export default function TelemetrySocket() {
|
||||
const phase = useTelemetryStore((s) => s.phase)
|
||||
const setPhase = useTelemetryStore((s) => s.setPhase)
|
||||
|
||||
// roster (persisted by ReadyOverlayHost)
|
||||
// roster store
|
||||
const rosterSet = useMatchRosterStore((s) => s.roster)
|
||||
const setRoster = useMatchRosterStore((s) => s.setRoster)
|
||||
const clearRoster = useMatchRosterStore((s) => s.clearRoster)
|
||||
|
||||
// local telemetry state
|
||||
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
|
||||
@ -121,7 +123,33 @@ export default function TelemetrySocket() {
|
||||
})()
|
||||
}, [])
|
||||
|
||||
// websocket connect
|
||||
// 🔸 Aktuelles Match + Roster laden (kein matchId-Prop nötig)
|
||||
const [currentMatchId, setCurrentMatchId] = useState<string | null>(null)
|
||||
const { lastEvent } = useSSEStore()
|
||||
|
||||
async function fetchCurrentRoster() {
|
||||
try {
|
||||
const r = await fetch('/api/matches/current', { cache: 'no-store' })
|
||||
if (!r.ok) return
|
||||
const j = await r.json()
|
||||
const ids: string[] = Array.isArray(j?.steamIds) ? j.steamIds : []
|
||||
setCurrentMatchId(j?.matchId ?? null)
|
||||
if (ids.length) setRoster(ids)
|
||||
else clearRoster()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCurrentRoster() }, []) // initial
|
||||
// ggf. bei relevanten Events nachziehen
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
const t = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
||||
if (['match-updated','match-ready','map-vote-updated','match-exported'].includes(String(t))) {
|
||||
fetchCurrentRoster()
|
||||
}
|
||||
}, [lastEvent])
|
||||
|
||||
// websocket connect (wie gehabt)
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
|
||||
@ -146,12 +174,10 @@ export default function TelemetrySocket() {
|
||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
||||
if (!msg) return
|
||||
|
||||
// --- server name (optional)
|
||||
if (msg.type === 'server' && typeof msg.name === 'string' && msg.name.trim()) {
|
||||
setServerName(msg.name.trim())
|
||||
}
|
||||
|
||||
// --- full roster
|
||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||
setSnapshot(msg.players)
|
||||
const ids = msg.players.map(sidOf).filter(Boolean)
|
||||
@ -162,7 +188,6 @@ export default function TelemetrySocket() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- incremental roster
|
||||
if (msg.type === 'player_join' && msg.player) {
|
||||
setJoin(msg.player)
|
||||
setTelemetrySet(prev => {
|
||||
@ -185,7 +210,7 @@ export default function TelemetrySocket() {
|
||||
})
|
||||
}
|
||||
|
||||
// --- map: NUR map + optional serverName, NICHT die Phase übernehmen
|
||||
// map nur für UI (Phase separat)
|
||||
if (msg.type === 'map' && typeof msg.name === 'string') {
|
||||
const key = msg.name.toLowerCase()
|
||||
setMapKey(key)
|
||||
@ -195,12 +220,12 @@ export default function TelemetrySocket() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- phase: AUSSCHLIESSLICHE Quelle für die Phase
|
||||
// Phase ausschließlich aus WS
|
||||
if (msg.type === 'phase' && typeof msg.phase === 'string') {
|
||||
setPhase(String(msg.phase).toLowerCase() as any)
|
||||
}
|
||||
|
||||
// --- score (unverändert)
|
||||
// Score
|
||||
if (msg.type === 'score') {
|
||||
const a = Number(msg.team1 ?? msg.ct)
|
||||
const b = Number(msg.team2 ?? msg.t)
|
||||
@ -221,7 +246,7 @@ export default function TelemetrySocket() {
|
||||
}
|
||||
}, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, hideOverlay, mySteamId])
|
||||
|
||||
// ----- banner logic (connected + disconnected variants) with roster fallback
|
||||
// ----- banner logic
|
||||
const myId = mySteamId ? String(mySteamId) : null
|
||||
const roster =
|
||||
rosterSet instanceof Set && rosterSet.size > 0
|
||||
@ -231,12 +256,8 @@ export default function TelemetrySocket() {
|
||||
const iAmExpected = !!myId && roster.has(myId)
|
||||
const iAmOnline = !!myId && telemetrySet.has(myId)
|
||||
|
||||
const intersectCount = (() => {
|
||||
if (roster.size === 0) return 0
|
||||
let n = 0
|
||||
for (const sid of roster) if (telemetrySet.has(sid)) n++
|
||||
return n
|
||||
})()
|
||||
let intersectCount = 0
|
||||
for (const sid of roster) if (telemetrySet.has(sid)) intersectCount++
|
||||
const totalExpected = roster.size
|
||||
|
||||
const connectUri =
|
||||
@ -245,10 +266,7 @@ export default function TelemetrySocket() {
|
||||
process.env.NEXT_PUBLIC_CS2_CONNECT_URI ||
|
||||
'steam://rungameid/730//+retry'
|
||||
|
||||
// Fallback-Label aus URI, falls kein Servername vom WS kam
|
||||
const fallbackServerLabel = parseServerLabel(connectUri)
|
||||
const effectiveServerLabel = (serverName && serverName.trim()) || fallbackServerLabel
|
||||
|
||||
const effectiveServerLabel = (serverName && serverName.trim()) || parseServerLabel(connectUri)
|
||||
const prettyPhase = phase ?? 'unknown'
|
||||
const prettyScore = (score.a == null || score.b == null) ? '– : –' : `${score.a} : ${score.b}`
|
||||
const prettyMapLabel = labelForMap(mapKeyForUi)
|
||||
@ -258,44 +276,28 @@ export default function TelemetrySocket() {
|
||||
}
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
// Auto-Reconnect stoppen
|
||||
aliveRef.current = false;
|
||||
if (retryRef.current) {
|
||||
window.clearTimeout(retryRef.current);
|
||||
retryRef.current = null;
|
||||
}
|
||||
|
||||
// WebSocket zu
|
||||
aliveRef.current = false
|
||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||
try { wsRef.current?.close(1000, 'user requested disconnect') } catch {}
|
||||
wsRef.current = null;
|
||||
wsRef.current = null
|
||||
setTelemetrySet(new Set())
|
||||
|
||||
// ❗ NICHT: setPhase('unknown'), setMapKeyForUi(null), setServerName(null)
|
||||
// Nur "Online"-Set leeren, damit variant = 'disconnected'
|
||||
setTelemetrySet(new Set());
|
||||
// Score darf bleiben; falls du willst, kannst du ihn optional leeren:
|
||||
// setScore({ a: null, b: null });
|
||||
|
||||
// Kick an Server schicken
|
||||
try {
|
||||
const who = myName || mySteamId;
|
||||
const who = myName || mySteamId
|
||||
if (who) {
|
||||
const cmd = `kick ${quoteArg(String(who))}`;
|
||||
const cmd = `kick ${quoteArg(String(who))}`
|
||||
await fetch('/api/cs2/server/send-command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
});
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn('[TelemetrySocket] kick command failed:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const variant: 'connected' | 'disconnected' = iAmOnline ? 'connected' : 'disconnected'
|
||||
const visible = iAmExpected
|
||||
const visible = iAmExpected // 👈 Banner nur für zugeordnete Spieler
|
||||
const zIndex = iAmOnline ? 9998 : 9999
|
||||
|
||||
const bannerEl = (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user