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[] {
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 } : {}),
},
})
})

View File

@ -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 })
}
}

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

View File

@ -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>
)}

View File

@ -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>
)}

View File

@ -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) }}
/>
)}

View File

@ -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"

View File

@ -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 = (