This commit is contained in:
Linrador 2025-09-22 21:12:25 +02:00
parent 8f88be26ce
commit c692cefb22
9 changed files with 545 additions and 351 deletions

View File

@ -348,26 +348,10 @@ function uniq<T>(arr: T[]) {
} }
function collectParticipants(match: any): string[] { function collectParticipants(match: any): string[] {
const fromMatchPlayers = const a = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
(match.players ?? []) const b = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
.map((mp: any) => mp?.user?.steamId) // ❗ keine Leader/activePlayers/match.players mehr NUR echte Teamspieler
.filter(Boolean) return Array.from(new Set<string>([...a, ...b]))
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,
])
} }
async function persistMatchPlayers(match: any) { async function persistMatchPlayers(match: any) {
@ -542,27 +526,11 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
data: { exportedAt: new Date() }, 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) { } catch (err) {
console.error('[mapvote] Export fehlgeschlagen:', err) console.error('[mapvote] Export fehlgeschlagen:', err)
} }
} }
/* ---------- kleine Helfer für match-ready Payload ---------- */ /* ---------- kleine Helfer für match-ready Payload ---------- */
function deriveChosenSteps(vote: any) { function deriveChosenSteps(vote: any) {
@ -649,8 +617,19 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
if (!current) { if (!current) {
// Kein Schritt mehr -> Vote abschließen // Kein Schritt mehr -> Vote abschließen
await prisma.mapVote.update({ where: { id: vote.id }, data: { locked: true } }) await prisma.mapVote.update({
const updated = await prisma.mapVote.findUnique({ where: { id: vote.id }, include: { steps: true } }) 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({ await sendServerSSEMessage({
type: 'map-vote-updated', type: 'map-vote-updated',
@ -700,7 +679,13 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
}) })
await tx.mapVote.update({ await tx.mapVote.update({
where: { id: vote.id }, 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({ await tx.mapVote.update({
where: { id: after.id }, where: { id: after.id },
data : { currentIdx: idx, locked }, data : {
currentIdx: idx,
locked,
// ➜ Nur wenn jetzt abgeschlossen: Admin-Edit beenden
...(locked ? { adminEditingBy: null, adminEditingSince: null } : {}),
},
}) })
}) })

View File

@ -50,22 +50,10 @@ function voteOpensAt(base: Date, leadMinutes: number) {
return new Date(base.getTime() - leadMinutes * 60_000) return new Date(base.getTime() - leadMinutes * 60_000)
} }
// Steps-Builder für BO1/BO3/BO5 // Steps-Builder für BO3/BO5
function buildSteps(bestOf: 1 | 3 | 5, firstTeamId: string | null, secondTeamId: string | null) { function buildSteps(bestOf: 3 | 5, firstTeamId: string | null, secondTeamId: string | null) {
const A = firstTeamId const A = firstTeamId
const B = secondTeamId 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) { if (bestOf === 3) {
// 2x Ban, 2x Pick, 2x Ban, Decider // 2x Ban, 2x Pick, 2x Ban, Decider
return [ return [
@ -108,7 +96,6 @@ export async function PUT(
teamAId, teamAId,
teamBId, teamBId,
matchDate, matchDate,
map,
voteLeadMinutes, // optional voteLeadMinutes, // optional
demoDate, demoDate,
bestOf: bestOfRaw, // <- NEU bestOf: bestOfRaw, // <- NEU
@ -116,7 +103,7 @@ export async function PUT(
// BestOf validieren (nur 1/3/5 zulassen) // BestOf validieren (nur 1/3/5 zulassen)
const bestOf = 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 { try {
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
@ -140,7 +127,6 @@ export async function PUT(
const updateData: any = {} const updateData: any = {}
if (typeof title !== 'undefined') updateData.title = title if (typeof title !== 'undefined') updateData.title = title
if (typeof matchType === 'string') updateData.matchType = matchType if (typeof matchType === 'string') updateData.matchType = matchType
if (typeof map !== 'undefined') updateData.map = map
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten
@ -318,7 +304,6 @@ export async function PUT(
teamBId: updated.teamBId, teamBId: updated.teamBId,
matchDate: updated.matchDate, matchDate: updated.matchDate,
demoDate: updated.demoDate, demoDate: updated.demoDate,
map: updated.map,
bestOf: updated.bestOf, bestOf: updated.bestOf,
mapVote: updated.mapVote, mapVote: updated.mapVote,
}, { headers: { 'Cache-Control': 'no-store' } }) }, { headers: { 'Cache-Control': 'no-store' } })
@ -327,3 +312,62 @@ export async function PUT(
return NextResponse.json({ error: 'Failed to update match meta' }, { status: 500 }) 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 })
}
}

View 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' } }
)
}

View File

@ -1,10 +1,11 @@
// app/components/EditMatchMetaModal.tsx // /src/app/components/EditMatchMetaModal.tsx
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Modal from '@/app/components/Modal' import Modal from '@/app/components/Modal'
import Alert from '@/app/components/Alert' import Alert from '@/app/components/Alert'
import Select from '@/app/components/Select' import Select from '@/app/components/Select'
import LoadingSpinner from '@/app/components/LoadingSpinner' // ⬅️ NEU
type TeamOption = { id: string; name: string; logo?: string | null } type TeamOption = { id: string; name: string; logo?: string | null }
@ -18,10 +19,10 @@ type Props = {
defaultTeamAName?: string | null defaultTeamAName?: string | null
defaultTeamBName?: string | null defaultTeamBName?: string | null
defaultDateISO?: 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 defaultVoteLeadMinutes?: number
onSaved?: () => void onSaved?: () => void
defaultBestOf?: 1 | 3 | 5 defaultBestOf?: 3 | 5
} }
export default function EditMatchMetaModal({ export default function EditMatchMetaModal({
@ -34,108 +35,159 @@ export default function EditMatchMetaModal({
defaultTeamAName, defaultTeamAName,
defaultTeamBName, defaultTeamBName,
defaultDateISO, defaultDateISO,
// defaultMap, // nicht mehr genutzt // defaultMap,
defaultVoteLeadMinutes = 60, defaultVoteLeadMinutes = 60,
onSaved, onSaved,
defaultBestOf = 3, defaultBestOf = 3,
}: Props) { }: 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 [title, setTitle] = useState(defaultTitle ?? '')
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '') const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '') const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes) const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
const [date, setDate] = useState<string>(toDatetimeLocal(defaultDateISO))
const [date, setDate] = useState<string>(() => { const [bestOf, setBestOf] = useState<3 | 5>(normalizeBestOf(defaultBestOf))
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 [teams, setTeams] = useState<TeamOption[]>([]) const [teams, setTeams] = useState<TeamOption[]>([])
const [loadingTeams, setLoadingTeams] = useState(false) const [loadingTeams, setLoadingTeams] = useState(false)
const [loadingMeta, setLoadingMeta] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = 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(() => { useEffect(() => {
if (!show) return if (!show) return
if (teamsFetchedRef.current) return
let alive = true
setLoadingTeams(true) setLoadingTeams(true)
;(async () => { ;(async () => {
try { try {
const res = await fetch('/api/teams', { cache: 'no-store' }) const res = await fetch('/api/teams', { cache: 'no-store' })
const data = res.ok ? await res.json() : [] const data = res.ok ? await res.json() : []
if (!alive) return
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? []) const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? [])
setTeams((list ?? []).filter((t: any) => t?.id && t?.name)) setTeams((list ?? []).filter((t: any) => t?.id && t?.name))
} catch (e) { } catch (e: any) {
if (!alive) return
console.error('[EditMatchMetaModal] load teams failed:', e) console.error('[EditMatchMetaModal] load teams failed:', e)
setTeams([]) setTeams([])
} finally { } finally {
setLoadingTeams(false) if (alive) {
setLoadingTeams(false)
teamsFetchedRef.current = true // Flag erst nach erfolgreichem Lauf
}
} }
})() })()
return () => {
alive = false
teamsFetchedRef.current = false
}
}, [show]) }, [show])
// Defaults beim Öffnen (einmal) /* ───────── Meta neu laden beim Öffnen ───────── */
useEffect(() => { useEffect(() => {
if (!show) { openedOnceRef.current = false; return } if (!show) return
if (openedOnceRef.current) return if (metaFetchedRef.current) return
openedOnceRef.current = true
setTitle(defaultTitle ?? '') let alive = true
setTeamAId(defaultTeamAId ?? '') setLoadingMeta(true)
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)
setError(null) 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( 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] [teams, teamBId]
) )
const teamOptionsB = useMemo( 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] [teams, teamAId]
) )
// Hinweis-Flag: Best Of geändert? /* ───────── Hinweis-Flag nur vs. /meta ───────── */
const defaultBestOfNormalized = (defaultBestOf ?? 3) as 1 | 3 | 5 const showBoChangedHint = metaBestOf !== null && bestOf !== metaBestOf
const bestOfChanged = bestOf !== defaultBestOfNormalized
// Validation /* ───────── Validation ───────── */
const canSave = useMemo(() => { const canSave = useMemo(() => {
if (saving) return false if (saving || loadingMeta) return false
if (!date) return false if (!date) return false
if (teamAId && teamBId && teamAId === teamBId) return false if (teamAId && teamBId && teamAId === teamBId) return false
return true 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 () => { const handleSave = async () => {
setSaving(true) setSaving(true)
setError(null) setError(null)
@ -146,7 +198,7 @@ export default function EditMatchMetaModal({
teamBId: teamBId || null, teamBId: teamBId || null,
matchDate: date ? new Date(date).toISOString() : null, matchDate: date ? new Date(date).toISOString() : null,
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60, voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
bestOf, // <- wichtig bestOf,
} }
const res = await fetch(`/api/matches/${matchId}/meta`, { 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 teamAPlaceholder = loadingTeams ? 'Teams laden …' : defaultTeamAName || 'Team A wählen …'
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B 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 ( return (
<Modal <Modal
@ -180,109 +235,125 @@ export default function EditMatchMetaModal({
show={show} show={show}
onClose={onClose} onClose={onClose}
onSave={handleSave} onSave={handleSave}
closeButtonTitle={saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'} closeButtonTitle={
saved ? '✓ Gespeichert' : saving ? 'Speichern …' : loadingMeta ? 'Lädt …' : 'Speichern'
}
closeButtonColor={saved ? 'green' : 'blue'} closeButtonColor={saved ? 'green' : 'blue'}
disableSave={!canSave} disableSave={!canSave}
maxWidth="sm:max-w-2xl" maxWidth="sm:max-w-2xl"
> >
{error && ( {error && !loadingMeta && (
<Alert type="soft" color="danger" className="mb-3"> <Alert type="soft" color="danger" className="mb-3">
{error} {error}
</Alert> </Alert>
)} )}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> {showOnlySpinner ? (
{/* Titel */} <div className="py-12 flex items-center justify-center">
<div className="col-span-2"> <LoadingSpinner />
<label className="block text-sm font-medium mb-1">Titel</label>
<input
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="z.B. Scrim vs. XYZ"
/>
</div> </div>
) : (
{/* Team A */} <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> {/* Titel */}
<label className="block text-sm font-medium mb-1">Team A</label> <div className="col-span-2">
<Select <label className="block text-sm font-medium mb-1">Titel</label>
options={teamOptionsA} <input
value={teamAId} className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
onChange={setTeamAId} value={title}
placeholder={teamAPlaceholder} onChange={(e) => setTitle(e.target.value)}
dropDirection="auto" placeholder="z.B. Scrim vs. XYZ"
/> 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"
/>
</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> </div>
{bestOfChanged && ( {/* Team A */}
<Alert type="soft" color="warning" className="mt-2"> <div>
Du hast den Modus von <b>BO{defaultBestOfNormalized}</b> auf <b>BO{bestOf}</b> geändert. <label className="block text-sm font-medium mb-1">Team A</label>
Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden verworfen). <Select
</Alert> 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>
</div> )}
</Modal> </Modal>
) )
} }

View File

@ -134,6 +134,9 @@ export default function MapVoteBanner({
const isLocked = !!state?.locked const isLocked = !!state?.locked
const isVotingOpen = isOpen && !isLocked const isVotingOpen = isOpen && !isLocked
const isEnded = !!state?.locked
const isLive = isOpen && !isEnded
const current = state?.steps?.[state?.currentIndex ?? 0] const current = state?.steps?.[state?.currentIndex ?? 0]
const whoIsUp = current?.teamId const whoIsUp = current?.teamId
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name) ? (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`) const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`)
// Farblogik: locked → grün, offen → gelb, noch geschlossen → neutral // Vorher: locked -> grün, live -> gelb
const ringClass = isLocked // Neu: live -> grün, locked -> grau
? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg' const ringClass = isLive
: isVotingOpen ? 'ring-1 ring-green-500/20 hover:ring-green-500/35 hover:shadow-lg'
? 'ring-1 ring-yellow-500/20 hover:ring-yellow-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' : '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' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200'
: isVotingOpen : isEnded
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100' ? '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' : 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700/40 dark:text-neutral-200'
const gradientClass = isLocked const gradientClass = isLive
? 'mapVoteGradient--green' ? 'mapVoteGradient--green'
: isVotingOpen : 'mapVoteGradient--none' // beendet und noch-nicht-offen: kein Effekt
? 'mapVoteGradient--yellow'
: 'mapVoteGradient--none'
return ( return (
<div <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="font-medium text-gray-900 dark:text-neutral-100">Map-Vote</div>
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate"> <div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
Modus: BO{match.bestOf ?? state?.bestOf ?? 3} Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
{state?.locked {isEnded
? ' • Auswahl fixiert' ? ' • Auswahl fixiert'
: isVotingOpen : isLive
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft') ? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
: ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`} : ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`}
</div> </div>
@ -210,19 +213,17 @@ export default function MapVoteBanner({
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{state?.locked ? ( {isEnded ? (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200"> <span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200">
Voting abgeschlossen Voting abgeschlossen
</span> </span>
) : isVotingOpen ? ( ) : isLive ? (
<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"> <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'} {iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
</span> </span>
) : ( ) : (
<span <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"
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>
suppressHydrationWarning
>
Öffnet in {mounted ? formatCountdown(msToOpen) : '::'} Öffnet in {mounted ? formatCountdown(msToOpen) : '::'}
</span> </span>
)} )}

View File

@ -880,6 +880,15 @@ export default function MapVotePanel({ match }: Props) {
? chosenSteps.slice(0, idx).filter(s => s.action === 'pick').length + 1 ? chosenSteps.slice(0, idx).filter(s => s.action === 'pick').length + 1
: null : 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 pickedByLeft = (action === 'pick' || action === 'decider') && pickTeamId === leftTeamId
const pickedByRight = (action === 'pick' || action === 'decider') && pickTeamId === rightTeamId const pickedByRight = (action === 'pick' || action === 'decider') && pickTeamId === rightTeamId
const cornerLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null 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) */} {/* Nummer oben links (für Pick UND Decider) */}
{typeof pickNumber === 'number' && ( {typeof displayNumber === 'number' && (
<span <span
className="absolute left-2 top-2 z-30 w-6 h-6 rounded-full 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 bg-white/95 text-neutral-900 text-xs md:text-sm font-bold
flex items-center justify-center shadow" flex items-center justify-center shadow"
title={`Pick ${pickNumber}`} title={
action === 'pick'
? `Pick ${displayNumber}`
: action === 'decider'
? `Decider (Map ${displayNumber})`
: undefined
}
> >
{pickNumber} {displayNumber}
</span> </span>
)} )}

View File

@ -562,9 +562,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
defaultDateISO={match.matchDate ?? match.demoDate ?? null} defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null} defaultMap={match.map ?? null}
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60} defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
// ⬇️ neu: defaultBestOf={Number(match.bestOf) === 5 ? 5 : 3}
defaultBestOf={(match.bestOf as 1 | 3 | 5) ?? 3}
defaultSeries={extractSeriesMaps(match)} // Array mit map-Keys (kann '' enthalten)
onSaved={() => { setTimeout(() => router.refresh(), 0) }} onSaved={() => { setTimeout(() => router.refresh(), 0) }}
/> />
)} )}

View File

@ -7,7 +7,6 @@ import { useSSEStore } from '@/app/lib/useSSEStore'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import { MAP_OPTIONS } from '../lib/mapOptions' import { MAP_OPTIONS } from '../lib/mapOptions'
import type { MapOption } from '../lib/mapOptions'
type Props = { type Props = {
open: boolean open: boolean
@ -42,14 +41,23 @@ export default function MatchReadyOverlay({
connectHref connectHref
}: Props) { }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const mySteamId = session?.user?.steamId const mySteamId = session?.user?.steamId ? String(session.user.steamId) : null
const { lastEvent } = useSSEStore() 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 ENV_CONNECT_HREF = process.env.NEXT_PUBLIC_CONNECT_HREF
const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000' const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000'
const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF
// Timer
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
const [startedAt] = useState(() => Date.now()) const [startedAt] = useState(() => Date.now())
const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt]) const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt])
@ -59,30 +67,29 @@ export default function MatchReadyOverlay({
// UI-States // UI-States
const [accepted, setAccepted] = useState(false) const [accepted, setAccepted] = useState(false)
const [finished, setFinished] = 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 [connecting, setConnecting] = useState(false)
const isVisible = open || accepted || showWaitHint
const [showConnectHelp, setShowConnectHelp] = useState(false) const [showConnectHelp, setShowConnectHelp] = useState(false)
const [showBackdrop, setShowBackdrop] = useState(false) const [showBackdrop, setShowBackdrop] = useState(false)
const [showContent, setShowContent] = 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 } type Participant = { steamId: string; name: string; avatar: string; team: 'A' | 'B' | null }
const [participants, setParticipants] = useState<Participant[]>([]) const [participants, setParticipants] = useState<Participant[]>([])
const [readyMap, setReadyMap] = useState<Record<string, string>>({}) const [readyMap, setReadyMap] = useState<Record<string, string>>({})
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [countReady, setCountReady] = useState(0) const [countReady, setCountReady] = useState(0)
// Presence-Map (SSE) // Presence (SSE)
const [statusMap, setStatusMap] = useState<Record<string, Presence>>({}) const [statusMap, setStatusMap] = useState<Record<string, Presence>>({})
const prevCountReadyRef = useRef<number>(0)
const ignoreNextIncreaseRef = useRef(false)
// ----- AUDIO ----- // ----- AUDIO -----
const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null) const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null)
const audioStartedRef = useRef(false) const audioStartedRef = useRef(false)
const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } } const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } }
const ensureAudioUnlocked = async () => { const ensureAudioUnlocked = async () => {
@ -127,33 +134,35 @@ export default function MatchReadyOverlay({
} }
try { onTimeout?.() } catch {} try { onTimeout?.() } catch {}
} }
// mini delay für UI Feedback
setTimeout(doConnect, 200) setTimeout(doConnect, 200)
}, [finished, effectiveConnectHref, onTimeout]) }, [finished, effectiveConnectHref, onTimeout])
// NUR nach Acceptance laden/aktualisieren // Ready-API nur nach Accept
const loadReady = useCallback(async () => { const loadReady = useCallback(async () => {
try { try {
const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' }) const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' })
if (!r.ok) return if (!r.ok) return
const j = await r.json() const j = await r.json()
setParticipants(j.participants ?? []) const parts: Participant[] = j.participants ?? []
setParticipants(parts)
setReadyMap(j.ready ?? {}) setReadyMap(j.ready ?? {})
setTotal(j.total ?? 0) setTotal(j.total ?? 0)
setCountReady(j.countReady ?? 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 {} } catch {}
}, [matchId]) }, [matchId])
// ---- Accept-Handling ---- // Accept
const postingRef = useRef(false) const postingRef = useRef(false)
const onAcceptClick = async () => { const onAcceptClick = async () => {
if (postingRef.current) return if (postingRef.current) return
postingRef.current = true postingRef.current = true
try { try {
stopBeeps() stopBeeps()
playMenuAccept() playMenuAccept()
const res = await fetch(`/api/matches/${matchId}/ready`, { const res = await fetch(`/api/matches/${matchId}/ready`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Ready-Accept': '1' }, headers: { 'Content-Type': 'application/json', 'X-Ready-Accept': '1' },
@ -161,7 +170,6 @@ export default function MatchReadyOverlay({
body: JSON.stringify({ intent: 'accept' }), body: JSON.stringify({ intent: 'accept' }),
}) })
if (!res.ok) return if (!res.ok) return
setAccepted(true) setAccepted(true)
try { await onAccept() } catch {} try { await onAccept() } catch {}
await loadReady() await loadReady()
@ -170,8 +178,7 @@ export default function MatchReadyOverlay({
} }
} }
// „Es lädt nicht?“ nach 30s
// ⬇️ NEU: nach 30s „Es lädt nicht?“ anzeigen
useEffect(() => { useEffect(() => {
let id: number | null = null let id: number | null = null
if (connecting) { if (connecting) {
@ -181,17 +188,15 @@ export default function MatchReadyOverlay({
return () => { if (id) window.clearTimeout(id) } return () => { if (id) window.clearTimeout(id) }
}, [connecting]) }, [connecting])
// Backdrop zuerst faden, dann Content // Backdrop Content
useEffect(() => { useEffect(() => {
if (!isVisible) { setShowBackdrop(false); setShowContent(false); return } if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return }
setShowBackdrop(true) setShowBackdrop(true)
const id = setTimeout(() => setShowContent(true), 300) // vorher: 2000 const id = setTimeout(() => setShowContent(true), 300)
return () => clearTimeout(id) return () => clearTimeout(id)
}, [isVisible]) }, [shouldRender])
if (!isVisible) return null // Nach Accept kurzer Refresh
// Nach Accept ein kurzer Refresh
useEffect(() => { useEffect(() => {
if (!accepted) return if (!accepted) return
const id = setTimeout(loadReady, 250) const id = setTimeout(loadReady, 250)
@ -199,12 +204,19 @@ export default function MatchReadyOverlay({
}, [accepted, loadReady]) }, [accepted, loadReady])
// SSE // SSE
const { lastEvent: le } = useSSEStore()
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
const payload = (lastEvent as any).payload?.payload ?? (lastEvent as any).payload ?? lastEvent 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 (type === 'ready-updated' && payload?.matchId === matchId) {
if (accepted) { if (accepted) {
const otherSteamId = payload?.steamId as string | undefined const otherSteamId = payload?.steamId as string | undefined
@ -223,15 +235,15 @@ export default function MatchReadyOverlay({
} }
}, [accepted, lastEvent, matchId, mySteamId, loadReady]) }, [accepted, lastEvent, matchId, mySteamId, loadReady])
// ----- simple mount animation flags ----- // Mount-Animation
const [fadeIn, setFadeIn] = useState(false) const [fadeIn, setFadeIn] = useState(false)
useEffect(() => { useEffect(() => {
if (!isVisible) { setFadeIn(false); return } if (!shouldRender) { setFadeIn(false); return }
const id = requestAnimationFrame(() => setFadeIn(true)) const id = requestAnimationFrame(() => setFadeIn(true))
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
}, [isVisible]) }, [shouldRender])
// ----- motion layer (video/gif) ----- // Motion-Layer
const prefersReducedMotion = useMemo( const prefersReducedMotion = useMemo(
() => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, () => 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) const [useGif, setUseGif] = useState<boolean>(() => !!forceGif || !!prefersReducedMotion)
useEffect(() => { useEffect(() => {
if (!isVisible) return if (!shouldRender) return
if (forceGif || prefersReducedMotion) { setUseGif(true); return } if (forceGif || prefersReducedMotion) { setUseGif(true); return }
const tryPlay = async () => { const tryPlay = async () => {
const v = videoRef.current const v = videoRef.current
@ -259,22 +271,21 @@ export default function MatchReadyOverlay({
} }
const id = setTimeout(tryPlay, 0) const id = setTimeout(tryPlay, 0)
return () => clearTimeout(id) return () => clearTimeout(id)
}, [isVisible, forceGif, prefersReducedMotion]) }, [shouldRender, forceGif, prefersReducedMotion])
useEffect(() => { useEffect(() => {
if (isVisible) return if (shouldRender) return
const v = videoRef.current const v = videoRef.current
if (v) { if (v) {
try { v.pause() } catch {} try { v.pause() } catch {}
v.removeAttribute('src') v.removeAttribute('src')
while (v.firstChild) v.removeChild(v.firstChild) while (v.firstChild) v.removeChild(v.firstChild)
} }
}, [isVisible]) }, [shouldRender])
// ----- AUDIO: Beeps starten/stoppen ----- // AUDIO: Beeps starten/stoppen
// Beeps erst starten, wenn der Content sichtbar ist
useEffect(() => { useEffect(() => {
if (!showContent) { // vorher: if (!isVisible) if (!showContent) {
stopBeeps() stopBeeps()
audioStartedRef.current = false audioStartedRef.current = false
return return
@ -300,53 +311,53 @@ export default function MatchReadyOverlay({
})() })()
return () => { cleanup(); stopBeeps() } return () => { cleanup(); stopBeeps() }
}, [showContent]) // vorher: [isVisible] }, [showContent])
// ⏩ Sofort verbinden, wenn alle bereit sind // Auto-Connect wenn alle bereit
useEffect(() => { useEffect(() => {
if (!isVisible) return if (!shouldRender) return
if (total > 0 && countReady >= total && !finished) { if (total > 0 && countReady >= total && !finished) {
startConnectingNow() startConnectingNow()
} }
}, [isVisible, total, countReady, finished, startConnectingNow]) }, [shouldRender, total, countReady, finished, startConnectingNow])
// ----- countdown / timeout ----- // Countdown
const rafRef = useRef<number | null>(null) const rafRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
if (!isVisible) return if (!shouldRender) return
const step = () => { const step = () => {
const t = Date.now() const t = Date.now()
setNow(t) setNow(t)
if (effectiveDeadline - t <= 0 && !finished) { if (effectiveDeadline - t <= 0 && !finished) {
if (accepted) { if (accepted) {
startConnectingNow() startConnectingNow()
} else { } else {
stopBeeps() stopBeeps()
setFinished(true) setFinished(true)
setShowWaitHint(true) setShowWaitHint(true)
}
return
} }
return
}
rafRef.current = requestAnimationFrame(step) rafRef.current = requestAnimationFrame(step)
} }
rafRef.current = requestAnimationFrame(step) rafRef.current = requestAnimationFrame(step)
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } 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 mapIconUrl = useMemo(() => {
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase() const norm = (s?: string | null) => (s ?? '').trim().toLowerCase()
const keyFromBg = /\/maps\/([^/]+)\//.exec(mapBg ?? '')?.[1] const keyFromBg = /\/maps\/([^/]+)\//.exec(mapBg ?? '')?.[1]
const byKey = keyFromBg ? MAP_OPTIONS.find(o => o.key === keyFromBg) : undefined 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 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 byImage = mapBg ? MAP_OPTIONS.find(o => o.images.includes(mapBg)) : undefined
const opt = byKey ?? byLabel ?? byImage const opt = byKey ?? byLabel ?? byImage
return opt?.icon ?? '/assets/img/mapicons/map_icon_lobby_mapveto.svg' return opt?.icon ?? '/assets/img/mapicons/map_icon_lobby_mapveto.svg'
}, [mapBg, mapLabel]) }, [mapBg, mapLabel])
// --- UI Helpers --- // ---------- RENDER ----------
if (!shouldRender) return null
const ReadyRow = () => ( const ReadyRow = () => (
<div className="absolute left-0 right-0 bottom-0 px-4 py-3"> <div className="absolute left-0 right-0 bottom-0 px-4 py-3">
<div className="flex items-center gap-2 mb-1 justify-center"> <div className="flex items-center gap-2 mb-1 justify-center">
@ -402,7 +413,7 @@ export default function MatchReadyOverlay({
return ( return (
<div className="fixed inset-0 z-[1000]"> <div className="fixed inset-0 z-[1000]">
{/* Backdrop: 2s-Fade */} {/* Backdrop */}
<div <div
className={[ className={[
'absolute inset-0 bg-black/60 transition-opacity duration-[300ms] ease-out', 'absolute inset-0 bg-black/60 transition-opacity duration-[300ms] ease-out',
@ -410,7 +421,7 @@ export default function MatchReadyOverlay({
].join(' ')} ].join(' ')}
/> />
{/* Content erst nach Backdrop-Delay */} {/* Content */}
{showContent && ( {showContent && (
<div <div
className={[ className={[
@ -428,7 +439,7 @@ export default function MatchReadyOverlay({
className="absolute inset-0 w-full h-full object-cover brightness-90" className="absolute inset-0 w-full h-full object-cover brightness-90"
/> />
{/* Deko-Layer (Gif/Video) */} {/* Motion-Layer */}
{useGif ? ( {useGif ? (
<div className="absolute inset-0 opacity-50 pointer-events-none"> <div className="absolute inset-0 opacity-50 pointer-events-none">
<img <img
@ -454,7 +465,7 @@ export default function MatchReadyOverlay({
</video> </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" /> <div className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
{/* Inhalt */} {/* Inhalt */}
@ -498,13 +509,11 @@ export default function MatchReadyOverlay({
</button> </button>
)} )}
{/* Countdown oder Verbinde-Status */} {/* Countdown / Verbinde-Status */}
{/* 🔽 NEU: Countdown ausblenden, wenn der Warte-Hinweis gezeigt wird */}
{!showWaitHint && ( {!showWaitHint && (
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]"> <div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
{connecting ? ( {connecting ? (
showConnectHelp ? ( 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="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> <span className="text-[#f8e08e] font-semibold">Es lädt nicht?</span>
<a <a
@ -515,7 +524,6 @@ export default function MatchReadyOverlay({
</a> </a>
</span> </span>
) : ( ) : (
// bisheriges „Verbinde…“
<span <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" 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" role="status"

View File

@ -9,6 +9,7 @@ import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore' import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore'
import TelemetryBanner from './TelemetryBanner' import TelemetryBanner from './TelemetryBanner'
import { MAP_OPTIONS } from '@/app/lib/mapOptions' import { MAP_OPTIONS } from '@/app/lib/mapOptions'
import { useSSEStore } from '@/app/lib/useSSEStore'
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) { function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
const h = (host ?? '').trim() || '127.0.0.1' const h = (host ?? '').trim() || '127.0.0.1'
@ -46,7 +47,6 @@ function labelForMap(key?: string | null): string {
const k = String(key).toLowerCase() const k = String(key).toLowerCase()
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === k) const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === k)
if (opt?.label) return opt.label 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() 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(' ') s = s.split(' ').map(w => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ')
return s return s
@ -84,8 +84,10 @@ export default function TelemetrySocket() {
const phase = useTelemetryStore((s) => s.phase) const phase = useTelemetryStore((s) => s.phase)
const setPhase = useTelemetryStore((s) => s.setPhase) const setPhase = useTelemetryStore((s) => s.setPhase)
// roster (persisted by ReadyOverlayHost) // roster store
const rosterSet = useMatchRosterStore((s) => s.roster) const rosterSet = useMatchRosterStore((s) => s.roster)
const setRoster = useMatchRosterStore((s) => s.setRoster)
const clearRoster = useMatchRosterStore((s) => s.clearRoster)
// local telemetry state // local telemetry state
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set()) 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(() => { useEffect(() => {
aliveRef.current = true aliveRef.current = true
@ -146,12 +174,10 @@ export default function TelemetrySocket() {
try { msg = JSON.parse(String(ev.data ?? '')) } catch {} try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return if (!msg) return
// --- server name (optional)
if (msg.type === 'server' && typeof msg.name === 'string' && msg.name.trim()) { if (msg.type === 'server' && typeof msg.name === 'string' && msg.name.trim()) {
setServerName(msg.name.trim()) setServerName(msg.name.trim())
} }
// --- full roster
if (msg.type === 'players' && Array.isArray(msg.players)) { if (msg.type === 'players' && Array.isArray(msg.players)) {
setSnapshot(msg.players) setSnapshot(msg.players)
const ids = msg.players.map(sidOf).filter(Boolean) 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) { if (msg.type === 'player_join' && msg.player) {
setJoin(msg.player) setJoin(msg.player)
setTelemetrySet(prev => { 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') { if (msg.type === 'map' && typeof msg.name === 'string') {
const key = msg.name.toLowerCase() const key = msg.name.toLowerCase()
setMapKey(key) 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') { if (msg.type === 'phase' && typeof msg.phase === 'string') {
setPhase(String(msg.phase).toLowerCase() as any) setPhase(String(msg.phase).toLowerCase() as any)
} }
// --- score (unverändert) // Score
if (msg.type === 'score') { if (msg.type === 'score') {
const a = Number(msg.team1 ?? msg.ct) const a = Number(msg.team1 ?? msg.ct)
const b = Number(msg.team2 ?? msg.t) const b = Number(msg.team2 ?? msg.t)
@ -221,7 +246,7 @@ export default function TelemetrySocket() {
} }
}, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, hideOverlay, mySteamId]) }, [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 myId = mySteamId ? String(mySteamId) : null
const roster = const roster =
rosterSet instanceof Set && rosterSet.size > 0 rosterSet instanceof Set && rosterSet.size > 0
@ -231,12 +256,8 @@ export default function TelemetrySocket() {
const iAmExpected = !!myId && roster.has(myId) const iAmExpected = !!myId && roster.has(myId)
const iAmOnline = !!myId && telemetrySet.has(myId) const iAmOnline = !!myId && telemetrySet.has(myId)
const intersectCount = (() => { let intersectCount = 0
if (roster.size === 0) return 0 for (const sid of roster) if (telemetrySet.has(sid)) intersectCount++
let n = 0
for (const sid of roster) if (telemetrySet.has(sid)) n++
return n
})()
const totalExpected = roster.size const totalExpected = roster.size
const connectUri = const connectUri =
@ -245,10 +266,7 @@ export default function TelemetrySocket() {
process.env.NEXT_PUBLIC_CS2_CONNECT_URI || process.env.NEXT_PUBLIC_CS2_CONNECT_URI ||
'steam://rungameid/730//+retry' 'steam://rungameid/730//+retry'
// Fallback-Label aus URI, falls kein Servername vom WS kam const effectiveServerLabel = (serverName && serverName.trim()) || parseServerLabel(connectUri)
const fallbackServerLabel = parseServerLabel(connectUri)
const effectiveServerLabel = (serverName && serverName.trim()) || fallbackServerLabel
const prettyPhase = phase ?? 'unknown' const prettyPhase = phase ?? 'unknown'
const prettyScore = (score.a == null || score.b == null) ? ' : ' : `${score.a} : ${score.b}` const prettyScore = (score.a == null || score.b == null) ? ' : ' : `${score.a} : ${score.b}`
const prettyMapLabel = labelForMap(mapKeyForUi) const prettyMapLabel = labelForMap(mapKeyForUi)
@ -258,44 +276,28 @@ export default function TelemetrySocket() {
} }
const handleDisconnect = async () => { const handleDisconnect = async () => {
// Auto-Reconnect stoppen aliveRef.current = false
aliveRef.current = false; if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
if (retryRef.current) {
window.clearTimeout(retryRef.current);
retryRef.current = null;
}
// WebSocket zu
try { wsRef.current?.close(1000, 'user requested disconnect') } catch {} 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 { try {
const who = myName || mySteamId; const who = myName || mySteamId
if (who) { if (who) {
const cmd = `kick ${quoteArg(String(who))}`; const cmd = `kick ${quoteArg(String(who))}`
await fetch('/api/cs2/server/send-command', { await fetch('/api/cs2/server/send-command', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
cache: 'no-store', cache: 'no-store',
body: JSON.stringify({ command: cmd }), body: JSON.stringify({ command: cmd }),
}); })
} }
} catch (err) { } catch {}
if (process.env.NODE_ENV !== 'production') { }
console.warn('[TelemetrySocket] kick command failed:', err);
}
}
};
const variant: 'connected' | 'disconnected' = iAmOnline ? 'connected' : 'disconnected' 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 zIndex = iAmOnline ? 9998 : 9999
const bannerEl = ( const bannerEl = (