This commit is contained in:
Linrador 2025-09-04 15:02:14 +02:00
parent 990c73beef
commit e93c00154a
12 changed files with 721 additions and 555 deletions

13
.env
View File

@ -19,6 +19,13 @@ PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
PTERO_SERVER_SFTP_USER=army.37a11489 PTERO_SERVER_SFTP_USER=army.37a11489
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
PTERO_SERVER_ID=37a11489 PTERO_SERVER_ID=37a11489
NEXT_PUBLIC_CS2_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_WS_PORT=443 # 🌍 Meta-WebSocket (CS2 Server Plugin)
NEXT_PUBLIC_CS2_WS_PATH=/telemetry NEXT_PUBLIC_CS2_META_WS_HOST=cs2.ironieopen.de
NEXT_PUBLIC_CS2_META_WS_PORT=443
NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry
# 🖥️ Positionen / GSI-WebSocket (lokaler Aggregator)
NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_POS_WS_PORT=8082
NEXT_PUBLIC_CS2_POS_WS_PATH=/positions

View File

@ -1,4 +1,5 @@
// /app/api/matches/[id]/mapvote/route.ts // /app/api/matches/[id]/mapvote/route.ts
import { NextResponse, NextRequest } from 'next/server' import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'

View File

@ -1,12 +1,13 @@
// /app/api/matches/[matchId]/meta/route.ts // /app/api/matches/[matchId]/meta/route.ts
import { NextResponse, type NextRequest } from 'next/server' import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/app/lib/auth'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client' import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
export const runtime = 'nodejs' // 👈 wie bei mapvote export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' // 👈 wie bei mapvote export const dynamic = 'force-dynamic'
// Hilfsfunktion: akzeptiert Date | string | number | null | undefined // Hilfsfunktion: akzeptiert Date | string | number | null | undefined
function parseDateOrNull(v: unknown): Date | null | undefined { function parseDateOrNull(v: unknown): Date | null | undefined {
@ -44,7 +45,7 @@ function parseDateOrNull(v: unknown): Date | null | undefined {
return undefined return undefined
} }
// wie in mapvote: Basiszeit -> opensAt // Basiszeit -> opensAt
function voteOpensAt(base: Date, leadMinutes: number) { function voteOpensAt(base: Date, leadMinutes: number) {
return new Date(base.getTime() - leadMinutes * 60_000) return new Date(base.getTime() - leadMinutes * 60_000)
} }
@ -90,7 +91,7 @@ export async function PUT(
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
// 1) Matching-Daten zusammenbauen // 1) Match-Felder zusammenbauen
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
@ -105,14 +106,14 @@ export async function PUT(
if (parsedDemoDate !== undefined) { if (parsedDemoDate !== undefined) {
updateData.demoDate = parsedDemoDate updateData.demoDate = parsedDemoDate
} else if (parsedMatchDate instanceof Date) { } else if (parsedMatchDate instanceof Date) {
// demoDate mitziehen, wenn matchDate geändert und demoDate nicht gesendet wurde // demoDate mitschieben, wenn matchDate geändert und demoDate nicht gesendet wurde
updateData.demoDate = parsedMatchDate updateData.demoDate = parsedMatchDate
} }
// 2) Lead bestimmen (Body > gespeicherter Wert > default 60) // 2) Lead bestimmen (Body > gespeicherter Wert > default 60)
const leadBodyRaw = Number(voteLeadMinutes) const leadBodyRaw = Number(voteLeadMinutes)
const leadBody = Number.isFinite(leadBodyRaw) ? leadBodyRaw : undefined const leadBody = Number.isFinite(leadBodyRaw) ? leadBodyRaw : undefined
const currentLead = match.mapVote?.leadMinutes ?? 60 // erfordert Feld im Schema const currentLead = match.mapVote?.leadMinutes ?? 60
const leadMinutes = leadBody ?? currentLead const leadMinutes = leadBody ?? currentLead
// 3) Basiszeit (neu oder alt) // 3) Basiszeit (neu oder alt)
@ -121,7 +122,7 @@ export async function PUT(
(match.matchDate ?? null) ?? (match.matchDate ?? null) ??
(match.demoDate ?? null) (match.demoDate ?? null)
// 4) Updaten & opensAt ggf. neu setzen analog zu mapvote // 4) Updaten & opensAt ggf. neu setzen
const updated = await prisma.$transaction(async (tx) => { const updated = await prisma.$transaction(async (tx) => {
const m = await tx.match.update({ const m = await tx.match.update({
where: { id }, where: { id },
@ -129,16 +130,13 @@ export async function PUT(
include: { mapVote: true }, include: { mapVote: true },
}) })
// Wenn wir eine Basiszeit haben → opensAt neu berechnen
if (baseDate) { if (baseDate) {
const opensAt = voteOpensAt(baseDate, leadMinutes) const opensAt = voteOpensAt(baseDate, leadMinutes)
if (!m.mapVote) { if (!m.mapVote) {
// MapVote existiert noch nicht → nur opensAt/leadMinutes anlegen (Schritte erstellt get /mapvote)
await tx.mapVote.create({ await tx.mapVote.create({
data: { data: {
matchId: m.id, matchId: m.id,
leadMinutes: leadMinutes, leadMinutes,
opensAt, opensAt,
}, },
}) })
@ -146,16 +144,16 @@ export async function PUT(
await tx.mapVote.update({ await tx.mapVote.update({
where: { id: m.mapVote.id }, where: { id: m.mapVote.id },
data: { data: {
...(leadBody !== undefined ? { leadMinutes: leadMinutes } : {}), ...(leadBody !== undefined ? { leadMinutes } : {}),
opensAt, opensAt,
}, },
}) })
} }
} else if (leadBody !== undefined && m.mapVote) { } else if (leadBody !== undefined && m.mapVote) {
// Keine Basiszeit-Änderung, aber Lead explizit gesetzt → nur leadMinutes persistieren // Nur Lead geändert
await tx.mapVote.update({ await tx.mapVote.update({
where: { id: m.mapVote.id }, where: { id: m.mapVote.id },
data: { leadMinutes: leadMinutes }, data: { leadMinutes },
}) })
} }
@ -171,32 +169,19 @@ export async function PUT(
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 }) if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
// 5) Events senden Shape identisch zur mapvote-Route // Immer map-vote-updated senden, wenn es einen MapVote gibt
// a) Für das Voting/Countdown: map-vote-updated (liefert opensAt) if (updated.mapVote) {
await sendServerSSEMessage({ await sendServerSSEMessage({
type: 'map-vote-updated', type: 'map-vote-updated',
payload: { payload: {
matchId: updated.id, matchId: updated.id,
opensAt: updated.mapVote?.opensAt ?? null, // JSON.stringify -> ISO leadMinutes: updated.mapVote.leadMinutes,
}, ...(updated.mapVote.opensAt ? { opensAt: updated.mapVote.opensAt } : {}),
}) },
})
}
// b) Zusätzlich Meta-Event für andere UIs // 6) Response
await sendServerSSEMessage({
type: 'match-meta-updated',
payload: {
matchId: updated.id,
updatedAt: new Date().toISOString(),
},
})
// (Optional) allgemeines match-updated, falls du andere Bereiche triggern willst
await sendServerSSEMessage({
type: 'match-updated',
payload: { matchId: updated.id, updatedAt: new Date().toISOString() },
})
// 6) Response (no-store)
return NextResponse.json({ return NextResponse.json({
id: updated.id, id: updated.id,
title: updated.title, title: updated.title,

View File

@ -1,5 +1,4 @@
// /app/components/MapVoteBanner.tsx // /app/components/MapVoteBanner.tsx
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
@ -35,18 +34,32 @@ function formatLead(minutes: number) {
return `${m}min` return `${m}min`
} }
export default function MapVoteBanner({
export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes }: Props) { match,
initialNow,
matchBaseTs,
sseOpensAtTs,
sseLeadMinutes,
}: Props) {
const router = useRouter() const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const [state, setState] = useState<MapVoteState | null>(null) const [state, setState] = useState<MapVoteState | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [leadOverride, setLeadOverride] = useState<number | null>(null); const [leadOverride, setLeadOverride] = useState<number | null>(null)
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null) const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
// deterministische Hydration + 1s-Ticker // ⚠️ Hydration-sicher: auf dem Server rendern wir ein statisches Placeholder
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// clientseitiger Ticker
const [now, setNow] = useState(initialNow) const [now, setNow] = useState(initialNow)
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@ -64,24 +77,17 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
setError(e?.message ?? 'Unbekannter Fehler') setError(e?.message ?? 'Unbekannter Fehler')
} }
}, [match.id]) }, [match.id])
// initial + bei Meta-Änderungen
useEffect(() => { load() }, [load])
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
const matchDateTs = useMemo( const matchDateTs = useMemo(
() => (typeof matchBaseTs === 'number' ? matchBaseTs : null), () => (typeof matchBaseTs === 'number' ? matchBaseTs : null),
[matchBaseTs] [matchBaseTs]
) )
useEffect(() => { // SSE: nur map-vote-updated & Co. beachten
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
// initial
useEffect(() => { load() }, [load])
// 🔁 Neu laden, wenn Match-Metadaten (z. B. matchDate/bestOf) sich durch refresh ändern
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
// 🔁 Live-Refresh via SSE
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
const { type } = lastEvent as any const { type } = lastEvent as any
@ -101,7 +107,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString()) ? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString())
: undefined : undefined
// sofortige lokale Overrides, ohne auf fetch zu warten // Sofort lokale Overrides setzen
if (nextOpensAtISO) { if (nextOpensAtISO) {
setOpensAtOverride(new Date(nextOpensAtISO).getTime()) setOpensAtOverride(new Date(nextOpensAtISO).getTime())
} else if (Number.isFinite(parsedLead) && matchDateTs != null) { } else if (Number.isFinite(parsedLead) && matchDateTs != null) {
@ -109,7 +115,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
} }
if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number) if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number)
// sichtbares Mergen (für UI-Text) // sichtbares Mergen (für UI-Texte)
if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) { if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) {
setState(prev => ({ setState(prev => ({
...(prev ?? {} as any), ...(prev ?? {} as any),
@ -117,28 +123,23 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}), ...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}),
}) as any) }) as any)
} else { } else {
// nur nachladen, wenn Event keine konkreten Werte trug
load() load()
} }
}, [lastEvent, match.id, matchDateTs, load]) }, [lastEvent, match.id, matchDateTs, load])
// Öffnet wann? // Öffnet wann? (Priorität: Parent-SSE → lokale SSE → Server → Fallback)
const opensAt = useMemo(() => { const opensAt = useMemo(() => {
// höchste Priorität: vom Parent (MatchDetails) gereichter TS
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs if (typeof sseOpensAtTs === 'number') return sseOpensAtTs
// dann lokaler SSE-Override
if (opensAtOverride != null) return opensAtOverride if (opensAtOverride != null) return opensAtOverride
// dann Serverwert aus /mapvote
if (state?.opensAt) return new Date(state.opensAt).getTime() if (state?.opensAt) return new Date(state.opensAt).getTime()
// Fallback aus Basis + Lead
if (matchDateTs == null) return new Date(initialNow).getTime() if (matchDateTs == null) return new Date(initialNow).getTime()
const lead = (typeof sseLeadMinutes === 'number') const lead = (typeof sseLeadMinutes === 'number')
? sseLeadMinutes ? sseLeadMinutes
: (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? state!.leadMinutes as number : 60)) : (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? (state!.leadMinutes as number) : 60))
return matchDateTs - lead * 60_000 return matchDateTs - lead * 60_000
}, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes]) }, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes])
// Text „startet X vor Matchbeginn“ // „startet X vor Matchbeginn“
const leadMinutes = useMemo(() => { const leadMinutes = useMemo(() => {
if (matchDateTs != null && opensAt != null) { if (matchDateTs != null && opensAt != null) {
return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000)) return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000))
@ -149,16 +150,16 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
return 60 return 60
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes]) }, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes])
const isOpen = now >= opensAt const isOpen = mounted && now >= opensAt
const msToOpen = Math.max(opensAt - now, 0) const msToOpen = Math.max(opensAt - now, 0)
const current = state?.steps?.[state.currentIndex] 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)
: null : null
const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session.user.steamId const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session?.user?.steamId
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session.user.steamId const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session?.user?.steamId
const isAdmin = !!session?.user?.isAdmin const isAdmin = !!session?.user?.isAdmin
const iCanAct = Boolean( const iCanAct = Boolean(
isOpen && isOpen &&
@ -176,7 +177,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
'dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ' + 'dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ' +
(isOpen (isOpen
? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg' ? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg'
: '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')
return ( return (
<div <div
@ -232,8 +233,12 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'} {iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
</span> </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"> // 🔑 Hydration-safe: vor dem Mount nur ein Placeholder rendern
Öffnet in {formatCountdown(msToOpen)} <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
>
Öffnet in {mounted ? formatCountdown(msToOpen) : '::'}
</span> </span>
)} )}
</div> </div>
@ -254,7 +259,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
); );
background-size: 200% 100%; background-size: 200% 100%;
background-repeat: repeat-x; background-repeat: repeat-x;
animation: slide-x 6s linear infinite; /* etwas ruhiger */ animation: slide-x 6s linear infinite;
} }
:global(.dark) .mapVoteGradient { :global(.dark) .mapVoteGradient {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
@ -290,12 +295,10 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
transform: translateX(-120%) skewX(-20deg); transform: translateX(-120%) skewX(-20deg);
transition: opacity .2s; transition: opacity .2s;
} }
/* nur wenn die Karte offen ist und gehovert wird */
:global(.group:hover) .shine::before { :global(.group:hover) .shine::before {
animation: shine 3.8s ease-out infinite; animation: shine 3.8s ease-out infinite;
} }
/* Respektiere Bewegungs-Präferenzen */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.mapVoteGradient { animation: none; } .mapVoteGradient { animation: none; }
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; } .shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }
@ -303,4 +306,4 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
`}</style> `}</style>
</div> </div>
) )
} }

View File

@ -1,3 +1,5 @@
// MapVotePanel.tsx
'use client' 'use client'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
@ -50,23 +52,62 @@ export default function MapVotePanel({ match }: Props) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [adminEditMode, setAdminEditMode] = useState(false) const [adminEditMode, setAdminEditMode] = useState(false)
const [overlayShownOnce, setOverlayShownOnce] = useState(false) const [overlayShownOnce, setOverlayShownOnce] = useState(false)
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
/* -------- Timers / open window -------- */ /* -------- Timers / open window -------- */
const opensAtTs = useMemo(() => { // ⚠️ Wichtig: hier keine Date.now()-abhängigen Ausgaben im SSR!
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
return base.getTime() - 60 * 60 * 1000 // stabile Basiszeit aus Props (SSR-safe; kein Date.now())
const matchBaseTs = useMemo(() => {
const raw = match.matchDate ?? match.demoDate ?? null
return raw ? new Date(raw).getTime() : null
}, [match.matchDate, match.demoDate]) }, [match.matchDate, match.demoDate])
const [nowTs, setNowTs] = useState(() => Date.now()) // vom Server geliefertes opensAt (hat Vorrang)
const opensAtTsFromServer = useMemo(() => {
return state?.opensAt ? new Date(state.opensAt).getTime() : null
}, [state?.opensAt])
const fallbackOpensAtTs = useMemo(() => {
return matchBaseTs != null ? matchBaseTs - 60 * 60 * 1000 : null
}, [matchBaseTs])
// Reihenfolge: SSE-Override → Server-Stand → Fallback
const openTs = opensAtOverrideTs ?? opensAtTsFromServer ?? fallbackOpensAtTs ?? null
// Mount-Flag, damit wir den Countdown erst clientseitig laufen lassen
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// Countdown-State (nur nach Mount updaten, sekundensynchron)
const [msLeft, setMsLeft] = useState(0)
useEffect(() => { useEffect(() => {
const t = setInterval(() => setNowTs(Date.now()), 1000) if (!mounted) return
return () => clearInterval(t) if (openTs == null) { setMsLeft(0); return }
}, [])
const update = () => setMsLeft(Math.max(openTs - Date.now(), 0))
update()
// an Sekundengrenze koppeln
const drift = 1000 - (Date.now() % 1000)
let intervalId: number | null = null
const timeoutId = window.setTimeout(() => {
update()
intervalId = window.setInterval(update, 1000)
}, drift)
return () => {
window.clearTimeout(timeoutId)
if (intervalId) window.clearInterval(intervalId)
}
}, [openTs, mounted])
const isOpen = mounted && (openTs != null ? msLeft <= 0 : false)
const msToOpen = msLeft
/* -------- Overlay integration -------- */ /* -------- Overlay integration -------- */
const overlayIsForThisMatch = overlayData?.matchId === match.id const overlayIsForThisMatch = overlayData?.matchId === match.id
// Merken: Overlay wurde für dieses Match mindestens einmal angezeigt
useEffect(() => { useEffect(() => {
if (overlayOpen && overlayIsForThisMatch) setOverlayShownOnce(true) if (overlayOpen && overlayIsForThisMatch) setOverlayShownOnce(true)
}, [overlayOpen, overlayIsForThisMatch]) }, [overlayOpen, overlayIsForThisMatch])
@ -109,12 +150,38 @@ export default function MapVotePanel({ match }: Props) {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// 🔔 Reagiere auf alle relevanten Match-Events (inkl. match-updated)
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
if (!MATCH_EVENTS.has(lastEvent.type)) return
if (lastEvent.payload?.matchId !== match.id) return // robustes Unwrapping (doppelte payload)
load() const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e
}, [lastEvent, match.id, load]) const evt = unwrap(lastEvent)
const type = lastEvent.type ?? evt?.type
if (evt?.matchId !== match.id) return
// ⬅️ Nur noch map-vote-updated für Counter/opensAt
if (type === 'map-vote-updated') {
const { opensAt, leadMinutes } = evt ?? {}
if (opensAt) {
const ts = new Date(opensAt).getTime()
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts)
setState(prev => (prev ? { ...prev, opensAt } : prev))
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) {
const ts = matchBaseTs - Number(leadMinutes) * 60_000
setOpensAtOverrideTs(ts)
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev))
}
}
// ⬅️ Nur Map-Vote-Events triggern ein Reload (kein match-updated mehr)
const MAPVOTE_REFRESH = new Set(['map-vote-updated', 'map-vote-reset', 'map-vote-admin-edit'])
if (MAPVOTE_REFRESH.has(type)) {
load()
}
}, [lastEvent, match.id, load, matchBaseTs])
/* -------- Admin-Edit Mirror -------- */ /* -------- Admin-Edit Mirror -------- */
const adminEditingBy = state?.adminEdit?.by ?? null const adminEditingBy = state?.adminEdit?.by ?? null
@ -125,14 +192,6 @@ export default function MapVotePanel({ match }: Props) {
}, [adminEditingEnabled, adminEditingBy, session?.user?.steamId]) }, [adminEditingEnabled, adminEditingBy, session?.user?.steamId])
/* -------- Derived flags & memoized maps -------- */ /* -------- Derived flags & memoized maps -------- */
const opensAt = useMemo(
() => (state?.opensAt ? new Date(state.opensAt).getTime() : null),
[state?.opensAt]
)
const isOpenFromMatch = nowTs >= opensAtTs
const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch
const msToOpen = Math.max((opensAt ?? opensAtTs) - nowTs, 0)
const me = session?.user const me = session?.user
const isAdmin = !!me?.isAdmin const isAdmin = !!me?.isAdmin
const mySteamId = me?.steamId const mySteamId = me?.steamId
@ -278,8 +337,7 @@ export default function MapVotePanel({ match }: Props) {
} }
if (holdMapRef.current === map) { if (holdMapRef.current === map) {
resetHold() resetHold()
setProgressByMap(prev => ({ ...prev, [map]: 0 })) setProgressByMap(prev => ({ ...prev, [map]: 0 }))}
}
}, [progressByMap, resetHold, finishAndSubmit]) }, [progressByMap, resetHold, finishAndSubmit])
const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => { const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => {
@ -495,7 +553,7 @@ export default function MapVotePanel({ match }: Props) {
)} )}
</span> </span>
) : isOpen ? ( ) : isOpen ? (
isFrozenByAdmin ? ( adminEditingEnabled && adminEditingBy !== session?.user?.steamId ? (
<span className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100 text-center"> <span className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100 text-center">
🔒 Admin-Edit aktiv Voting pausiert 🔒 Admin-Edit aktiv Voting pausiert
{(() => { {(() => {
@ -519,8 +577,11 @@ export default function MapVotePanel({ match }: Props) {
</span> </span>
) )
) : ( ) : (
<span className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 text-center"> <span
Öffnet in {fmtCountdown(msToOpen)} className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 text-center"
suppressHydrationWarning
>
Öffnet in {mounted && openTs != null ? fmtCountdown(msToOpen) : '::'}
</span> </span>
)} )}
</div> </div>
@ -541,17 +602,21 @@ export default function MapVotePanel({ match }: Props) {
<div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start"> <div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start">
{/* Linke Spalte */} {/* Linke Spalte */}
<div className={`flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out ${ <div className={`flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out ${
leftIsActiveTurn ? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]' : 'bg-transparent shadow-none' !!currentStep?.teamId &&
currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) &&
!state?.locked
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
: 'bg-transparent shadow-none'
}`}> }`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img src={getTeamLogo(teamLeft?.logo)} alt={teamLeft?.name ?? 'Team'} className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" width={12} height={12} /> <img src={getTeamLogo(match.teamA?.logo)} alt={match.teamA?.name ?? 'Team'} className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" width={12} height={12} />
<div className="min-w-0"> <div className="min-w-0">
<div className="font-bold text-lg truncate">{teamLeft?.name ?? 'Team'}</div> <div className="font-bold text-lg truncate">{match.teamA?.name ?? 'Team'}</div>
</div> </div>
<TeamPremierRankBadge players={rankLeft} /> <TeamPremierRankBadge players={playersA.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any} />
</div> </div>
{playersLeft.map((p: MatchPlayer) => ( {playersA.map((p: MatchPlayer) => (
<MapVoteProfileCard <MapVoteProfileCard
key={p.user.steamId} key={p.user.steamId}
steamId={p.user.steamId} steamId={p.user.steamId}
@ -561,8 +626,8 @@ export default function MapVotePanel({ match }: Props) {
rank={p.stats?.rankNew ?? 0} rank={p.stats?.rankNew ?? 0}
matchType={match.matchType} matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)} onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId} isLeader={(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) === p.user.steamId}
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id) && !state.locked} isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) && !state.locked}
/> />
))} ))}
</div> </div>
@ -597,22 +662,37 @@ export default function MapVotePanel({ match }: Props) {
const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}` const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
// für DECIDER den „Chooser“ ermitteln (letztes Ban davor)
const steps = state?.steps ?? []
const decIdx = steps.findIndex(s => s.action === 'decider')
let deciderChooserTeamId: string | null = null
if (decIdx >= 0) {
for (let i = decIdx - 1; i >= 0; i--) {
const s = steps[i]
if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break }
}
}
const effectiveTeamId = const effectiveTeamId =
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === teamLeft?.id const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === match.teamA?.id
const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === teamRight?.id const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === match.teamB?.id
const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg`
// ⬇️ Fortschritt aus den TOP-LEVEL Hooks nutzen
const progress = progressByMap[map] ?? 0 const progress = progressByMap[map] ?? 0
const showProgress = isAvailable && progress > 0 && progress < 1 const showProgress = isAvailable && progress > 0 && progress < 1
const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg` const disabledTitle = isFrozenByAdmin
const disabledTitle = isFrozenByAdmin ? 'Ein Admin bearbeitet gerade Voting gesperrt' : 'Nur der Team-Leader (oder Admin) darf wählen' ? 'Ein Admin bearbeitet gerade Voting gesperrt'
: 'Nur der Team-Leader (oder Admin) darf wählen'
return ( return (
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2"> <li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
{pickedByLeft ? ( {pickedByLeft ? (
<img src={getTeamLogo(teamLeft?.logo)} alt={teamLeft?.name ?? 'Team'} className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" /> <img src={getTeamLogo(match.teamA?.logo)} alt={match.teamA?.name ?? 'Team'} className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" />
) : <div className="w-10 h-10" />} ) : <div className="w-10 h-10" />}
<Button <Button
@ -628,9 +708,9 @@ export default function MapVotePanel({ match }: Props) {
onMouseDown={() => onHoldStart(map, isAvailable)} onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)} onMouseUp={() => cancelOrSubmitIfComplete(map)}
onMouseLeave={() => cancelOrSubmitIfComplete(map)} onMouseLeave={() => cancelOrSubmitIfComplete(map)}
onTouchStart={onTouchStart(map, isAvailable)} onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
onTouchEnd={onTouchEnd(map)} onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
onTouchCancel={onTouchEnd(map)} onTouchCancel={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
> >
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} /> <div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
{showProgress && ( {showProgress && (
@ -639,16 +719,16 @@ export default function MapVotePanel({ match }: Props) {
{taken && (status === 'ban' || status === 'pick' || status === 'decider') && ( {taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
<> <>
{(((status === 'ban' && teamId === teamLeft?.id) || {(((status === 'ban' && teamId === match.teamA?.id) ||
(status === 'pick' && effectiveTeamId === teamLeft?.id) || (status === 'pick' && effectiveTeamId === match.teamA?.id) ||
(status === 'decider' && effectiveTeamId === teamLeft?.id))) && ( (status === 'decider' && effectiveTeamId === match.teamA?.id))) && (
<span className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded transition duration-300 ease-out ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}> <span className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded transition duration-300 ease-out ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
{status === 'ban' ? 'Ban' : 'Pick'} {status === 'ban' ? 'Ban' : 'Pick'}
</span> </span>
)} )}
{(((status === 'ban' && teamId === teamRight?.id) || {(((status === 'ban' && teamId === match.teamB?.id) ||
(status === 'pick' && effectiveTeamId === teamRight?.id) || (status === 'pick' && effectiveTeamId === match.teamB?.id) ||
(status === 'decider' && effectiveTeamId === teamRight?.id))) && ( (status === 'decider' && effectiveTeamId === match.teamB?.id))) && (
<span className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}> <span className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
{status === 'ban' ? 'Ban' : 'Pick'} {status === 'ban' ? 'Ban' : 'Pick'}
</span> </span>
@ -669,7 +749,7 @@ export default function MapVotePanel({ match }: Props) {
</Button> </Button>
{pickedByRight ? ( {pickedByRight ? (
<img src={getTeamLogo(teamRight?.logo)} alt={teamRight?.name ?? 'Team'} className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" /> <img src={getTeamLogo(match.teamB?.logo)} alt={match.teamB?.name ?? 'Team'} className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" />
) : <div className="w-10 h-10" />} ) : <div className="w-10 h-10" />}
</li> </li>
) )
@ -679,17 +759,21 @@ export default function MapVotePanel({ match }: Props) {
{/* Rechte Spalte */} {/* Rechte Spalte */}
<div className={`flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out ${ <div className={`flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out ${
rightIsActiveTurn ? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]' : 'bg-transparent shadow-none' !!currentStep?.teamId &&
currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) &&
!state?.locked
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
: 'bg-transparent shadow-none'
}`}> }`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<TeamPremierRankBadge players={rankRight} /> <TeamPremierRankBadge players={playersB.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any} />
<div className="min-w-0 text-right"> <div className="min-w-0 text-right">
<div className="font-bold text-lg truncate">{teamRight?.name ?? 'Team'}</div> <div className="font-bold text-lg truncate">{match.teamB?.name ?? 'Team'}</div>
</div> </div>
<img src={getTeamLogo(teamRight?.logo)} alt={teamRight?.name ?? 'Team'} className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" width={12} height={12} /> <img src={getTeamLogo(match.teamB?.logo)} alt={match.teamB?.name ?? 'Team'} className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" width={12} height={12} />
</div> </div>
{playersRight.map((p: MatchPlayer) => ( {playersB.map((p: MatchPlayer) => (
<MapVoteProfileCard <MapVoteProfileCard
key={p.user.steamId} key={p.user.steamId}
steamId={p.user.steamId} steamId={p.user.steamId}
@ -699,8 +783,8 @@ export default function MapVotePanel({ match }: Props) {
rank={p.stats?.rankNew ?? 0} rank={p.stats?.rankNew ?? 0}
matchType={match.matchType} matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)} onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId} isLeader={(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === p.user.steamId}
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamRightKey]?.id ?? teamRight?.id) && !state.locked} isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) && !state.locked}
/> />
))} ))}
</div> </div>
@ -717,16 +801,19 @@ export default function MapVotePanel({ match }: Props) {
const chosenSteps = (state.steps ?? []).filter( const chosenSteps = (state.steps ?? []).filter(
s => (s.action === 'pick' || s.action === 'decider') && s.map s => (s.action === 'pick' || s.action === 'decider') && s.map
) )
const decIdx = (state.steps ?? []).findIndex(s => s.action === 'decider') // ermitteln, wer beim DECIDER "zählt"
const steps = state?.steps ?? []
const decIdx = steps.findIndex(s => s.action === 'decider')
let chooserTeamId: string | null = null let chooserTeamId: string | null = null
if (decIdx >= 0) { if (decIdx >= 0) {
for (let i = decIdx - 1; i >= 0; i--) { for (let i = decIdx - 1; i >= 0; i--) {
const s = state.steps![i] const s = steps[i]
if (s.action === 'ban' && s.teamId) { chooserTeamId = s.teamId; break } if (s.action === 'ban' && s.teamId) { chooserTeamId = s.teamId; break }
} }
} }
const teamLeftLogo = getTeamLogo(teamLeft?.logo)
const teamRightLogo = getTeamLogo(teamRight?.logo) const teamLeftLogo = getTeamLogo(match.teamA?.logo)
const teamRightLogo = getTeamLogo(match.teamB?.logo)
return ( return (
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
@ -740,8 +827,8 @@ export default function MapVotePanel({ match }: Props) {
const pickTeamId = action === 'pick' ? (step?.teamId ?? null) const pickTeamId = action === 'pick' ? (step?.teamId ?? null)
: action === 'decider' ? chooserTeamId : action === 'decider' ? chooserTeamId
: null : null
const pickedByLeft = pickTeamId && pickTeamId === teamLeft?.id const pickedByLeft = pickTeamId && pickTeamId === match.teamA?.id
const pickedByRight = pickTeamId && pickTeamId === teamRight?.id const pickedByRight = pickTeamId && pickTeamId === match.teamB?.id
const sideLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null const sideLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null
const frameClasses = const frameClasses =
action === 'pick' ? 'ring-2 ring-green-500' action === 'pick' ? 'ring-2 ring-green-500'

View File

@ -1,8 +1,7 @@
// /app/components/MatchDetails.tsx // /app/components/MatchDetails.tsx
'use client' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { format } from 'date-fns' import { format } from 'date-fns'
@ -23,7 +22,6 @@ import { useSSEStore } from '@/app/lib/useSSEStore'
import { Team } from '../types/team' import { Team } from '../types/team'
import Alert from './Alert' import Alert from './Alert'
import Image from 'next/image' import Image from 'next/image'
import { MATCH_EVENTS } from '../lib/sseEvents'
import Link from 'next/link' import Link from 'next/link'
type TeamWithPlayers = Team & { players?: MatchPlayer[] } type TeamWithPlayers = Team & { players?: MatchPlayer[] }
@ -50,8 +48,7 @@ type VoteStep = { order: number; action: VoteAction; map?: string | null }
const mapLabelFromKey = (key?: string) => { const mapLabelFromKey = (key?: string) => {
const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'') const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'')
return ( return (
MAP_OPTIONS.find(o => o.key === k)?.label ?? MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD')
(k ? k : 'TBD')
) )
} }
@ -62,7 +59,6 @@ function extractSeriesMaps(match: Match): string[] {
.filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER')) .filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER'))
.sort((a,b) => (a.order ?? 0) - (b.order ?? 0)) .sort((a,b) => (a.order ?? 0) - (b.order ?? 0))
.map(s => s.map ?? '') .map(s => s.map ?? '')
// auf bestOf begrenzen
const n = Math.max(1, match.bestOf ?? 1) const n = Math.max(1, match.bestOf ?? 1)
return picks.slice(0, n) return picks.slice(0, n)
} }
@ -83,13 +79,11 @@ function SeriesStrip({
const needed = Math.ceil(bestOf / 2) const needed = Math.ceil(bestOf / 2)
const total = Math.max(bestOf, maps.length || 1) const total = Math.max(bestOf, maps.length || 1)
// index der "aktuellen" Map: sobald jemand fertig ist → keine aktuelle Markierung
const finished = winsA >= needed || winsB >= needed const finished = winsA >= needed || winsB >= needed
const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1) const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1)
return ( return (
<div className="w-full"> <div className="w-full">
{/* Kopfzeile der Serie */}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-400"> <div className="text-sm text-gray-400">
Best of {bestOf} First to {needed} Best of {bestOf} First to {needed}
@ -99,14 +93,11 @@ function SeriesStrip({
</div> </div>
</div> </div>
{/* Kartenleiste */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{Array.from({ length: total }).map((_, i) => { {Array.from({ length: total }).map((_, i) => {
const key = maps[i] ?? '' const key = maps[i] ?? ''
const label = mapLabelFromKey(key) const label = mapLabelFromKey(key)
// Siegerbadge pro Map (heuristisch): mapsiegereihenfolge = Sum wins so far?
// Da wir kein per-Map Ergebnis haben, markieren wir nur globalen Fortschritt:
const isDone = i < winsA + winsB const isDone = i < winsA + winsB
const isCurrent = i === currentIdx const isCurrent = i === currentIdx
const isFuture = i > winsA + winsB const isFuture = i > winsA + winsB
@ -144,18 +135,22 @@ function SeriesStrip({
) )
} }
/* ─────────────────── Komponente ─────────────────────────────── */ /* ─────────────────── Komponente ─────────────────────────────── */
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) { export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
const { data: session } = useSession() const { data: session } = useSession()
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const router = useRouter() const router = useRouter()
const isAdmin = !!session?.user?.isAdmin const isAdmin = !!session?.user?.isAdmin
// Hydration-sicher: keine sich ändernden Werte im SSR rendern
// Wir brauchen "now" nur, um zu entscheiden, ob Mapvote schon gestartet ist
const [now, setNow] = useState(initialNow) const [now, setNow] = useState(initialNow)
const [editMetaOpen, setEditMetaOpen] = useState(false) const [editMetaOpen, setEditMetaOpen] = useState(false)
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null);
const [leadOverride, setLeadOverride] = useState<number | null>(null);
// Lokale Overrides (analog MapVoteBanner), damit die Clients sofort reagieren
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
const [leadOverride, setLeadOverride] = useState<number | null>(null)
const lastHandledKeyRef = useRef<string>('')
/* ─── Rollen & Rechte ─────────────────────────────────────── */ /* ─── Rollen & Rechte ─────────────────────────────────────── */
const me = session?.user const me = session?.user
@ -168,14 +163,14 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? [] const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? [] const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
/* ─── Map ─────────────────────────────────────────────────── */ /* ─── Map-Label ───────────────────────────────────────────── */
const mapKey = normalizeMapKey(match.map) const mapKey = normalizeMapKey(match.map)
const mapLabel = const mapLabel =
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ?? MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ?? MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ??
'Unbekannte Map' 'Unbekannte Map'
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */ /* ─── Match-Zeitpunkt (vom Server; ändert sich via router.refresh) ─── */
const dateString = match.matchDate ?? match.demoDate const dateString = match.matchDate ?? match.demoDate
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt' const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
@ -185,32 +180,33 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')] return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')]
}, [match.bestOf, match.mapVote?.steps?.length]) }, [match.bestOf, match.mapVote?.steps?.length])
/* ─── Modal-State ─────────────────────────────────────────── */
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
const [editSide, setEditSide] = useState<EditSide | null>(null) const [editSide, setEditSide] = useState<EditSide | null>(null)
/* ─── Live-Uhr (für vote-Zeitpunkt) ───────────────────────── */ /* ─── Live-Uhr für Mapvote-Startfenster ───────────────────── */
useEffect(() => { useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000) const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
// Basiszeit des Matches (stabil; für Berechnung von opensAt-Fallback)
// Basiszeit des Matches einmal berechnen
const matchBaseTs = useMemo(() => { const matchBaseTs = useMemo(() => {
const raw = match.matchDate ?? match.demoDate ?? initialNow; const raw = match.matchDate ?? match.demoDate ?? initialNow
return new Date(raw).getTime(); return new Date(raw).getTime()
}, [match.matchDate, match.demoDate, initialNow]); }, [match.matchDate, match.demoDate, initialNow])
// Zeitpunkt, wann der Mapvote öffnet (Parent errechnet und an Banner gereicht)
const voteOpensAtTs = useMemo(() => { const voteOpensAtTs = useMemo(() => {
if (opensAtOverride != null) return opensAtOverride; // SSE hat Vorrang if (opensAtOverride != null) return opensAtOverride
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime(); // vom Server if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime()
const lead = (leadOverride != null) ? leadOverride : 60; // kein 60-min Zwang const lead = (leadOverride != null)
return matchBaseTs - lead * 60_000; ? leadOverride
}, [opensAtOverride, match.mapVote?.opensAt, matchBaseTs, leadOverride]); : (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
return matchBaseTs - lead * 60_000
const sseOpensAtTs = voteOpensAtTs; }, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride])
const sseLeadMinutes = leadOverride;
const sseOpensAtTs = voteOpensAtTs
const sseLeadMinutes = leadOverride
const endDate = new Date(voteOpensAtTs) const endDate = new Date(voteOpensAtTs)
const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs
@ -218,39 +214,53 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
const showEditA = canEditA && !mapvoteStarted const showEditA = canEditA && !mapvoteStarted
const showEditB = canEditB && !mapvoteStarted const showEditB = canEditB && !mapvoteStarted
/* ─── SSE-Listener ─────────────────────────────────────────── */ /* ─── SSE-Listener (nur map-vote-updated & Co.) ───────────── */
useEffect(() => { useEffect(() => {
if (!lastEvent) return; if (!lastEvent) return
const evt = (lastEvent as any).payload ?? lastEvent;
if (evt?.matchId !== match.id) return;
if (lastEvent.type === 'map-vote-updated') { // robustes Unwrap
// opensAt aus Event übernehmen const outer = lastEvent as any
const maybeInner = outer?.payload
const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner)
? maybeInner
: outer
const type = base?.type
const evt = base?.payload ?? base
if (!evt?.matchId || evt.matchId !== match.id) return
// Dedupe-Key
const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}`
if (key === lastHandledKeyRef.current) {
// identisches Event bereits verarbeitet → ignorieren
return
}
lastHandledKeyRef.current = key
// eigentliche Verarbeitung
if (type === 'map-vote-updated') {
if (evt?.opensAt) { if (evt?.opensAt) {
const ts = typeof evt.opensAt === 'string' const ts = new Date(evt.opensAt).getTime()
? new Date(evt.opensAt).getTime() setOpensAtOverride(ts)
: new Date(evt.opensAt).getTime();
setOpensAtOverride(ts);
} }
// leadMinutes mitschneiden u. ggf. opensAt daraus ableiten
if (Number.isFinite(evt?.leadMinutes)) { if (Number.isFinite(evt?.leadMinutes)) {
const lead = Number(evt.leadMinutes); const lead = Number(evt.leadMinutes)
setLeadOverride(lead); setLeadOverride(lead)
if (!evt?.opensAt) { if (!evt?.opensAt) {
const base = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime(); const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
setOpensAtOverride(base - lead * 60_000); setOpensAtOverride(baseTs - lead * 60_000)
} }
} }
// damit match.matchDate & Co. neu vom Server kommen
router.refresh()
return
} }
const REFRESH_TYPES = new Set([ const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
'map-vote-reset','map-vote-locked','map-vote-unlocked', if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) {
'match-updated','match-lineup-updated', router.refresh()
]);
if (REFRESH_TYPES.has(lastEvent.type) && evt?.matchId === match.id) {
router.refresh();
} }
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow]); }, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */ /* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
const ColGroup = () => ( const ColGroup = () => (
@ -292,21 +302,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Table.Head> <Table.Head>
<Table.Row> <Table.Row>
{[ {[
'Spieler', 'Spieler', 'Rank', 'Aim', 'K', 'A', 'D',
'Rank', '1K', '2K', '3K', '4K', '5K',
'Aim', 'K/D', 'ADR', 'HS%', 'Damage',
'K',
'A',
'D',
'1K',
'2K',
'3K',
'4K',
'5K',
'K/D',
'ADR',
'HS%',
'Damage',
].map((h) => ( ].map((h) => (
<Table.Cell key={h} as="th"> <Table.Cell key={h} as="th">
{h} {h}
@ -333,11 +331,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Table.Cell> <Table.Cell>
<div className="flex items-center gap-[6px]"> <div className="flex items-center gap-[6px]">
{match.matchType === 'premier' ? ( {match.matchType === 'premier'
<PremierRankBadge rank={p.stats?.rankNew ?? 0} /> ? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
) : ( : <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
)}
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && ( {match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span <span
className={`text-sm ${ className={`text-sm ${
@ -384,14 +380,12 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<div className="space-y-6"> <div className="space-y-6">
{/* Kopfzeile: Zurück + Admin-Buttons */} {/* Kopfzeile: Zurück + Admin-Buttons */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Links: Zurück */}
<Link href="/schedule"> <Link href="/schedule">
<Button color="gray" variant="outline"> <Button color="gray" variant="outline">
Zurück Zurück
</Button> </Button>
</Link> </Link>
{/* Rechts: Admin-Buttons */}
{isAdmin && ( {isAdmin && (
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -414,6 +408,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
Match auf {mapLabel} ({match.matchType}) Match auf {mapLabel} ({match.matchType})
</h1> </h1>
{/* Hydration-sicher: Datum kommt vom Server und ändert sich nach SSE via router.refresh() */}
<p className="text-sm text-gray-500">Datum: {readableDate}</p> <p className="text-sm text-gray-500">Datum: {readableDate}</p>
{(match.bestOf ?? 1) > 1 && ( {(match.bestOf ?? 1) > 1 && (
@ -422,7 +417,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
bestOf={match.bestOf ?? 3} bestOf={match.bestOf ?? 3}
scoreA={match.scoreA} scoreA={match.scoreA}
scoreB={match.scoreB} scoreB={match.scoreB}
maps={seriesMaps} maps={extractSeriesMaps(match)}
/> />
</div> </div>
)} )}
@ -435,6 +430,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0} <strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div> </div>
{/* MapVote-Banner erhält die aktuell berechneten (SSE-konformen) Werte */}
<MapVoteBanner <MapVoteBanner
match={match} match={match}
initialNow={initialNow} initialNow={initialNow}
@ -452,25 +448,18 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4"> <Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showEditA ? ( {canEditA && !mapvoteStarted ? (
<> <svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
{/* Unlocked-Icon */} <path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640"> </svg>
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/> ) : (
</svg> <svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
</> <path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
) : ( </svg>
<> )}
{/* Locked-Icon */}
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
</svg>
</>
)
}
<span className='text-gray-300'> <span className='text-gray-300'>
{showEditA ? ( {canEditA && !mapvoteStarted ? (
<> <>
Du kannst die Aufstellung noch bis{' '} Du kannst die Aufstellung noch bis{' '}
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten. <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
@ -483,10 +472,10 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Button <Button
size="sm" size="sm"
onClick={() => showEditA && setEditSide('A')} onClick={() => (canEditA && !mapvoteStarted) && setEditSide('A')}
disabled={!showEditA} disabled={!(canEditA && !mapvoteStarted)}
className={`px-3 py-1.5 text-sm rounded-lg ${ className={`px-3 py-1.5 text-sm rounded-lg ${
showEditA canEditA && !mapvoteStarted
? 'bg-blue-600 hover:bg-blue-700 text-white' ? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-400 text-gray-200 cursor-not-allowed' : 'bg-gray-400 text-gray-200 cursor-not-allowed'
}`} }`}
@ -506,11 +495,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{match.teamB?.logo && ( {match.teamB?.logo && (
<span className="relative inline-block w-8 h-8 mr-2 align-middle"> <span className="relative inline-block w-8 h-8 mr-2 align-middle">
<Image <Image
src={ src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
match.teamB.logo
? `/assets/img/logos/${match.teamB.logo}`
: `/assets/img/logos/cs2.webp`
}
alt="Teamlogo" alt="Teamlogo"
fill fill
sizes="64px" sizes="64px"
@ -522,28 +507,20 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{match.teamB?.name ?? 'Team B'} {match.teamB?.name ?? 'Team B'}
</h2> </h2>
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4"> <Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showEditB ? ( {canEditB && !mapvoteStarted ? (
<> <svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
{/* Unlocked-Icon */} <path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640"> </svg>
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/> ) : (
</svg> <svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
</> <path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
) : ( </svg>
<> )}
{/* Locked-Icon */}
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
</svg>
</>
)
}
<span className='text-gray-300'> <span className='text-gray-300'>
{showEditB ? ( {canEditB && !mapvoteStarted ? (
<> <>
Du kannst die Aufstellung noch bis{' '} Du kannst die Aufstellung noch bis{' '}
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten. <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
@ -556,10 +533,10 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Button <Button
size="sm" size="sm"
onClick={() => showEditB && setEditSide('B')} onClick={() => (canEditB && !mapvoteStarted) && setEditSide('B')}
disabled={!showEditB} disabled={!(canEditB && !mapvoteStarted)}
className={`px-3 py-1.5 text-sm rounded-lg ${ className={`px-3 py-1.5 text-sm rounded-lg ${
showEditB canEditB && !mapvoteStarted
? 'bg-blue-600 hover:bg-blue-700 text-white' ? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-400 text-gray-200 cursor-not-allowed' : 'bg-gray-400 text-gray-200 cursor-not-allowed'
}`} }`}
@ -584,7 +561,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
side={editSide} side={editSide}
initialA={teamAPlayers.map((mp) => mp.user.steamId)} initialA={teamAPlayers.map((mp) => mp.user.steamId)}
initialB={teamBPlayers.map((mp) => mp.user.steamId)} initialB={teamBPlayers.map((mp) => mp.user.steamId)}
onSaved={() => router.refresh()} // sanfter als window.location.reload() onSaved={() => router.refresh()}
/> />
)} )}
@ -600,7 +577,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
defaultTeamBName={match.teamB?.name ?? null} defaultTeamBName={match.teamB?.name ?? null}
defaultDateISO={match.matchDate ?? match.demoDate ?? null} defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null} defaultMap={match.map ?? null}
defaultVoteLeadMinutes={60} defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
onSaved={() => { setTimeout(() => router.refresh(), 0) }} onSaved={() => { setTimeout(() => router.refresh(), 0) }}
/> />
)} )}

View File

@ -1,23 +1,24 @@
// /app/components/LiveRadar.tsx // /app/components/radar/LiveRadar.tsx
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import MetaSocket from './MetaSocket'
import PositionsSocket from './PositionsSocket'
/* ───────────────── UI ───────────────── */ /* ───────── UI config ───────── */
const UI = { const UI = {
player: { player: {
minRadiusPx: 4, minRadiusPx: 4,
radiusRel: 0.008, // relativ zur kleineren Bildkante radiusRel: 0.008,
dirLenRel: 0.70, // Anteil des Radius dirLenRel: 0.70,
dirMinLenPx: 6, dirMinLenPx: 6,
lineWidthRel: 0.25, lineWidthRel: 0.25,
stroke: '#ffffff', stroke: '#ffffff',
fillCT: '#3b82f6', fillCT: '#3b82f6',
fillT: '#f59e0b', fillT: '#f59e0b',
dirColor: 'auto' as 'auto' | string, // 'auto' = Kontrast zum Kreis dirColor: 'auto' as 'auto' | string,
}, },
/* ───────────────── UI (Grenades) ───────────────── */
nade: { nade: {
stroke: '#111111', stroke: '#111111',
smokeFill: 'rgba(160,160,160,0.35)', smokeFill: 'rgba(160,160,160,0.35)',
@ -27,10 +28,11 @@ const UI = {
decoyFill: 'rgba(140,140,255,0.25)', decoyFill: 'rgba(140,140,255,0.25)',
teamStrokeCT: '#3b82f6', teamStrokeCT: '#3b82f6',
teamStrokeT: '#f59e0b', teamStrokeT: '#f59e0b',
minRadiusPx: 6 minRadiusPx: 6,
} },
} }
/* ───────── helpers ───────── */
function contrastStroke(hex: string) { function contrastStroke(hex: string) {
const h = hex.replace('#','') const h = hex.replace('#','')
const r = parseInt(h.slice(0,2),16)/255 const r = parseInt(h.slice(0,2),16)/255
@ -47,25 +49,31 @@ function mapTeam(t: any): 'T' | 'CT' | string {
return String(t ?? '') return String(t ?? '')
} }
const RAD2DEG = 180 / Math.PI; function buildWsUrl(prefix: 'CS2_META' | 'CS2_POS') {
const host = process.env[`NEXT_PUBLIC_${prefix}_WS_HOST`] || '127.0.0.1'
const port = String(process.env[`NEXT_PUBLIC_${prefix}_WS_PORT`] || (prefix === 'CS2_META' ? '443' : '8082'))
const path = process.env[`NEXT_PUBLIC_${prefix}_WS_PATH`] || '/telemetry'
function normalizeDeg(d: number) { // Heuristik: wenn explizit 443 -> wss, wenn Seite https und Host != localhost -> wss, sonst ws
d = d % 360; const isLocal = ['127.0.0.1', 'localhost', '::1'].includes(host)
return d < 0 ? d + 360 : d; const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
const proto = (port === '443' || (!isLocal && pageHttps)) ? 'wss' : 'ws'
const portPart = (port === '80' || port === '443') ? '' : `:${port}`
return `${proto}://${host}${portPart}${path}`
} }
function parseVec3String(str?: string) {
if (!str || typeof str !== 'string') return { x: 0, y: 0, z: 0 };
const [x, y, z] = str.split(',').map(s => Number(s.trim()));
return {
x: Number.isFinite(x) ? x : 0,
y: Number.isFinite(y) ? y : 0,
z: Number.isFinite(z) ? z : 0,
};
}
function asNum(n: any, def=0) { const v = Number(n); return Number.isFinite(v) ? v : def }
/* ───────────────── Types ───────────────── */ const RAD2DEG = 180 / Math.PI
const normalizeDeg = (d: number) => (d % 360 + 360) % 360
const parseVec3String = (str?: string) => {
if (!str || typeof str !== 'string') return { x: 0, y: 0, z: 0 }
const [x, y, z] = str.split(',').map(s => Number(s.trim()))
return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0, z: Number.isFinite(z) ? z : 0 }
}
const asNum = (n: any, def=0) => { const v = Number(n); return Number.isFinite(v) ? v : def }
/* ───────── types ───────── */
type PlayerState = { type PlayerState = {
id: string id: string
name?: string | null name?: string | null
@ -73,10 +81,9 @@ type PlayerState = {
x: number x: number
y: number y: number
z: number z: number
yaw?: number | null // Grad yaw?: number | null
alive?: boolean alive?: boolean
} }
type Grenade = { type Grenade = {
id: string id: string
kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown' kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown'
@ -87,24 +94,22 @@ type Grenade = {
expiresAt?: number | null expiresAt?: number | null
team?: 'T' | 'CT' | string | null team?: 'T' | 'CT' | string | null
} }
type Overview = { posX: number; posY: number; scale: number; rotate?: number } type Overview = { posX: number; posY: number; scale: number; rotate?: number }
type Mapper = (xw: number, yw: number) => { x: number; y: number } type Mapper = (xw: number, yw: number) => { x: number; y: number }
/* ───────────────── Komponente ───────────────── */ /* ───────── Komponente ───────── */
export default function LiveRadar() { export default function LiveRadar() {
const [wsStatus, setWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle') // WS-Status separat anzeigen
const [metaWsStatus, setMetaWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
const [posWsStatus, setPosWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
// Zustand
const [activeMapKey, setActiveMapKey] = useState<string | null>(null) const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
// Spieler (throttled)
const playersRef = useRef<Map<string, PlayerState>>(new Map()) const playersRef = useRef<Map<string, PlayerState>>(new Map())
const [players, setPlayers] = useState<PlayerState[]>([]) const [players, setPlayers] = useState<PlayerState[]>([])
// Grenades (throttled)
const grenadesRef = useRef<Map<string, Grenade>>(new Map()) const grenadesRef = useRef<Map<string, Grenade>>(new Map())
const [grenades, setGrenades] = useState<Grenade[]>([]) const [grenades, setGrenades] = useState<Grenade[]>([])
// gemeinsamer Flush (Players + Grenades)
const flushTimer = useRef<number | null>(null) const flushTimer = useRef<number | null>(null)
const scheduleFlush = () => { const scheduleFlush = () => {
if (flushTimer.current != null) return if (flushTimer.current != null) return
@ -114,233 +119,193 @@ export default function LiveRadar() {
setGrenades(Array.from(grenadesRef.current.values())) setGrenades(Array.from(grenadesRef.current.values()))
}, 66) }, 66)
} }
/* ───────────── WebSocket ───────────── */
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return return () => {
if (flushTimer.current != null) {
const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL window.clearTimeout(flushTimer.current)
const host = process.env.NEXT_PUBLIC_CS2_WS_HOST || window.location.hostname flushTimer.current = null
const port = process.env.NEXT_PUBLIC_CS2_WS_PORT || ''
const path = process.env.NEXT_PUBLIC_CS2_WS_PATH || '/telemetry'
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const portPart = port && port !== '80' && port !== '443' ? `:${port}` : ''
const url = explicit || `${proto}://${host}${portPart}${path}`
let alive = true
let ws: WebSocket | null = null
let retry: number | null = null
const upsertPlayer = (e: any) => {
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
if (!id) return
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates
const x = Number(e.x ?? pos?.x ?? (Array.isArray(pos) ? pos?.[0] : undefined))
const y = Number(e.y ?? pos?.y ?? (Array.isArray(pos) ? pos?.[1] : undefined))
const z = Number(e.z ?? pos?.z ?? (Array.isArray(pos) ? pos?.[2] : 0))
if (!Number.isFinite(x) || !Number.isFinite(y)) return
const yaw = Number(
e.yaw ??
e.viewAngle?.yaw ??
e.view?.yaw ??
e.aim?.yaw ??
e.ang?.y ??
e.angles?.y ??
e.rotation?.yaw
)
playersRef.current.set(id, {
id,
name: e.name ?? null,
team: mapTeam(e.team),
x, y, z,
yaw: Number.isFinite(yaw) ? yaw : null,
alive: e.alive,
})
}
// >>> GSI-Zuschauer-Format verarbeiten
const handleAllPlayers = (msg: any) => {
const ap = msg?.allplayers
if (!ap || typeof ap !== 'object') return
for (const key of Object.keys(ap)) {
const p = ap[key]
const pos = parseVec3String(p.position) // "x, y, z" -> {x,y,z}
const fwd = parseVec3String(p.forward)
// yaw aus forward (x,y)
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
const id = String(key) // in GSI-Snapshots ist das meist die Entität/Steam-ähnliche ID
playersRef.current.set(id, {
id,
name: p.name ?? null,
team: mapTeam(p.team),
x: pos.x,
y: pos.y,
z: pos.z,
yaw,
alive: p.state?.health > 0 || p.state?.health == null ? true : false,
})
} }
} }
}, [])
// Grenades normalisieren (tolerant gegen versch. Formate) const metaUrl = buildWsUrl('CS2_META')
const posUrl = buildWsUrl('CS2_POS')
/* ───────── Meta-Callbacks ───────── */
const handleMetaMap = (key: string) => setActiveMapKey(key.toLowerCase())
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
for (const p of list) {
const id = String(p.steamId ?? '')
if (!id) continue
const old = playersRef.current.get(id)
playersRef.current.set(id, {
id,
name: p.name ?? old?.name ?? null,
team: mapTeam(p.team ?? old?.team),
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: old?.alive,
})
}
scheduleFlush()
}
const handleMetaPlayerJoin = (p: any) => {
const id = String(p?.steamId ?? p?.id ?? p?.name ?? '')
if (!id) return
const old = playersRef.current.get(id)
playersRef.current.set(id, {
id,
name: p?.name ?? old?.name ?? null,
team: mapTeam(p?.team ?? old?.team),
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: true,
})
scheduleFlush()
}
const handleMetaPlayerLeave = (steamId: string | number) => {
const id = String(steamId)
const old = playersRef.current.get(id)
if (old) {
playersRef.current.set(id, { ...old, alive: false })
scheduleFlush()
}
}
/* ───────── Positions-Callbacks ───────── */
const upsertPlayer = (e: any) => {
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
if (!id) return
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates
const x = Number(e.x ?? pos?.x ?? (Array.isArray(pos) ? pos?.[0] : undefined))
const y = Number(e.y ?? pos?.y ?? (Array.isArray(pos) ? pos?.[1] : undefined))
const z = Number(e.z ?? pos?.z ?? (Array.isArray(pos) ? pos?.[2] : 0))
if (!Number.isFinite(x) || !Number.isFinite(y)) return
const yaw = Number(
e.yaw ??
e.viewAngle?.yaw ??
e.view?.yaw ??
e.aim?.yaw ??
e.ang?.y ??
e.angles?.y ??
e.rotation?.yaw
)
const old = playersRef.current.get(id)
playersRef.current.set(id, {
id,
name: e.name ?? old?.name ?? null,
team: mapTeam(e.team ?? old?.team),
x, y, z,
yaw: Number.isFinite(yaw) ? yaw : old?.yaw ?? null,
alive: e.alive ?? old?.alive,
})
}
const handlePlayersAll = (msg: any) => {
const ap = msg?.allplayers
if (!ap || typeof ap !== 'object') return
for (const key of Object.keys(ap)) {
const p = ap[key]
const pos = parseVec3String(p.position)
const fwd = parseVec3String(p.forward)
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
const id = String(key)
const old = playersRef.current.get(id)
playersRef.current.set(id, {
id,
name: p.name ?? old?.name ?? null,
team: mapTeam(p.team ?? old?.team),
x: pos.x, y: pos.y, z: pos.z,
yaw,
alive: p.state?.health > 0 || p.state?.health == null ? true : false,
})
}
}
const normalizeGrenades = (raw: any): Grenade[] => {
if (!raw) return []
const pickTeam = (t: any): 'T' | 'CT' | string | null => { const pickTeam = (t: any): 'T' | 'CT' | string | null => {
const s = mapTeam(t) const s = mapTeam(t)
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null) return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null)
} }
const normalizeGrenades = (raw: any): Grenade[] => { if (Array.isArray(raw)) {
if (!raw) return [] return raw.map((g: any, i: number) => {
const pos = g.pos ?? g.position ?? g.location ?? {}
// 1) Falls schon Array [{type, pos{x,y,z}, ...}] const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
if (Array.isArray(raw)) { typeof pos === 'string' ? parseVec3String(pos) : pos
return raw.map((g: any, i: number) => { return {
const pos = g.pos ?? g.position ?? g.location ?? {} id: String(g.id ?? `${g.type ?? 'nade'}#${i}`),
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } : kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']),
typeof pos === 'string' ? parseVec3String(pos) : pos x: asNum(g.x ?? xyz?.x), y: asNum(g.y ?? xyz?.y), z: asNum(g.z ?? xyz?.z),
return { radius: Number.isFinite(Number(g.radius)) ? Number(g.radius) : null,
id: String(g.id ?? `${g.type ?? 'nade'}#${i}`), expiresAt: Number.isFinite(Number(g.expiresAt)) ? Number(g.expiresAt) : null,
kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']), team: pickTeam(g.team ?? g.owner_team ?? g.side ?? null),
x: asNum(g.x ?? xyz?.x), y: asNum(g.y ?? xyz?.y), z: asNum(g.z ?? xyz?.z), }
radius: Number.isFinite(Number(g.radius)) ? Number(g.radius) : null, })
expiresAt: Number.isFinite(Number(g.expiresAt)) ? Number(g.expiresAt) : null, }
team: pickTeam(g.team ?? g.owner_team ?? g.side ?? null) const buckets: Record<string, string[]> = {
} as Grenade smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'],
he: ['he', 'hegrenade', 'hegrenades', 'explosive'],
flash: ['flash', 'flashbang', 'flashbangs'],
decoy: ['decoy', 'decoys'],
}
const out: Grenade[] = []
const push = (kind: Grenade['kind'], list: any) => {
if (!list) return
const arr = Array.isArray(list) ? list : Object.values(list)
let i = 0
for (const g of arr) {
const pos = g?.pos ?? g?.position ?? g?.location
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
typeof pos === 'string' ? parseVec3String(pos) :
(pos || { x: g?.x, y: g?.y, z: g?.z })
const id = String(
g?.id ?? g?.entityid ?? g?.entindex ??
`${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${i++}`
)
out.push({
id, kind,
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : null,
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null,
team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null),
}) })
} }
// 2) Objekt mit Buckets (smokes, flashbangs, ...)
const buckets: Record<string, string[]> = {
smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'],
he: ['he', 'hegrenade', 'hegrenades', 'explosive'],
flash: ['flash', 'flashbang', 'flashbangs'],
decoy: ['decoy', 'decoys'],
}
const out: Grenade[] = []
const push = (kind: Grenade['kind'], list: any) => {
if (!list) return
const arr = Array.isArray(list) ? list : Object.values(list)
let i = 0
for (const g of arr) {
const pos = g?.pos ?? g?.position ?? g?.location
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
typeof pos === 'string' ? parseVec3String(pos) :
(pos || { x: g?.x, y: g?.y, z: g?.z })
const id = String(
g?.id ??
g?.entityid ??
g?.entindex ??
`${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${i++}`
)
out.push({
id,
kind,
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : null,
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null,
team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null),
})
}
}
for (const [kind, keys] of Object.entries(buckets)) {
for (const k of keys) {
if ((raw as any)[k]) push(kind as Grenade['kind'], (raw as any)[k])
}
}
// 3) Generischer Fallback: dict {typeKey -> items}
if (out.length === 0 && typeof raw === 'object') {
for (const [k, v] of Object.entries(raw)) {
const kk = k.toLowerCase()
const kind =
kk.includes('smoke') ? 'smoke' :
kk.includes('flash') ? 'flash' :
kk.includes('molotov') || kk.includes('inferno') || kk.includes('fire') ? 'molotov' :
kk.includes('decoy') ? 'decoy' :
kk.includes('he') ? 'he' :
'unknown'
push(kind as Grenade['kind'], v)
}
}
return out
} }
for (const [kind, keys] of Object.entries(buckets)) {
const ingestGrenades = (g: any) => { for (const k of keys) if ((raw as any)[k]) push(kind as Grenade['kind'], (raw as any)[k])
const list = normalizeGrenades(g)
const next = new Map<string, Grenade>()
for (const it of list) next.set(it.id, it)
grenadesRef.current = next
} }
if (out.length === 0 && typeof raw === 'object') {
const dispatch = (m: any) => { for (const [k, v] of Object.entries(raw)) {
if (!m) return const kk = k.toLowerCase()
// Map aus verschiedenen Formaten abgreifen const kind =
if (m.type === 'map' || m.type === 'level' || m.map) { kk.includes('smoke') ? 'smoke' :
const key = m.name || m.map || m.level || m.map?.name kk.includes('flash') ? 'flash' :
if (typeof key === 'string' && key) setActiveMapKey(key.toLowerCase()) kk.includes('molotov') || kk.includes('inferno') || kk.includes('fire') ? 'molotov' :
} kk.includes('decoy') ? 'decoy' :
// GSI Zuschauer-Format kk.includes('he') ? 'he' : 'unknown'
if (m.allplayers) handleAllPlayers(m) push(kind as Grenade['kind'], v)
// Tick-Paket deines Servers
if (m.type === 'tick') {
if (typeof m.map === 'string' && m.map) setActiveMapKey(m.map.toLowerCase())
if (Array.isArray(m.players)) for (const p of m.players) dispatch(p)
if (m.grenades) ingestGrenades(m.grenades)
}
// Einzelspieler/Einzelevent
if (m.steamId || m.steam_id || m.pos || m.position) upsertPlayer(m)
// Grenades ggf. separat
if (m.grenades && m.type !== 'tick') ingestGrenades(m.grenades)
}
const connect = () => {
if (!alive) return
setWsStatus('connecting')
ws = new WebSocket(url)
ws.onopen = () => setWsStatus('open')
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (Array.isArray(msg)) {
for (const e of msg) dispatch(e)
} else if (msg?.type === 'tick' && Array.isArray(msg.players)) {
if (typeof msg.map === 'string' && msg.map) setActiveMapKey(msg.map.toLowerCase())
for (const p of msg.players) dispatch(p)
if (msg.grenades) dispatch({ grenades: msg.grenades })
} else if (msg) {
if (msg?.map?.name && typeof msg.map.name === 'string') setActiveMapKey(msg.map.name.toLowerCase())
dispatch(msg)
}
scheduleFlush()
}
ws.onerror = () => setWsStatus('error')
ws.onclose = () => {
setWsStatus('closed')
if (alive) retry = window.setTimeout(connect, 2000)
} }
} }
return out
}
const handleGrenades = (g: any) => {
const list = normalizeGrenades(g)
const next = new Map<string, Grenade>()
for (const it of list) next.set(it.id, it)
grenadesRef.current = next
}
connect() // gemeinsamer flush bei Positionsdaten
return () => { useEffect(() => {
alive = false if (!playersRef.current && !grenadesRef.current) return
if (retry) window.clearTimeout(retry) scheduleFlush()
try { ws?.close(1000, 'radar unmounted') } catch {} // eslint-disable-next-line react-hooks/exhaustive-deps
}
}, []) }, [])
/* ───────── Overview + Radarbild ───────── */
/* ───────────── Overview laden ───────────── */
const [overview, setOverview] = useState<Overview | null>(null) const [overview, setOverview] = useState<Overview | null>(null)
const overviewCandidates = (mapKey: string) => { const overviewCandidates = (mapKey: string) => {
const base = mapKey const base = mapKey
@ -387,7 +352,6 @@ export default function LiveRadar() {
return () => { cancel = true } return () => { cancel = true }
}, [activeMapKey]) }, [activeMapKey])
/* ───────────── Radarbild ───────────── */
const { folderKey, imageCandidates } = useMemo(() => { const { folderKey, imageCandidates } = useMemo(() => {
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] } if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
@ -439,7 +403,6 @@ export default function LiveRadar() {
const { posX, posY, scale, rotate = 0 } = overview const { posX, posY, scale, rotate = 0 } = overview
const w = imgSize.w, h = imgSize.h const w = imgSize.w, h = imgSize.h
const cx = w / 2, cy = h / 2 const cx = w / 2, cy = h / 2
const bases: ((xw: number, yw: number) => { x: number; y: number })[] = [ const bases: ((xw: number, yw: number) => { x: number; y: number })[] = [
(xw, yw) => ({ x: (xw - posX) / scale, y: (posY - yw) / scale }), (xw, yw) => ({ x: (xw - posX) / scale, y: (posY - yw) / scale }),
(xw, yw) => ({ x: (posX - xw) / scale, y: (posY - yw) / scale }), (xw, yw) => ({ x: (posX - xw) / scale, y: (posY - yw) / scale }),
@ -481,7 +444,7 @@ export default function LiveRadar() {
const unitsToPx = useMemo(() => { const unitsToPx = useMemo(() => {
if (!imgSize) return (u: number) => u if (!imgSize) return (u: number) => u
if (overview) { if (overview) {
const scale = overview.scale // world units per pixel const scale = overview.scale
return (u: number) => u / scale return (u: number) => u / scale
} }
const R = 4096 const R = 4096
@ -491,21 +454,22 @@ export default function LiveRadar() {
}, [imgSize, overview]) }, [imgSize, overview])
/* ───────── Status-Badge ───────── */ /* ───────── Status-Badge ───────── */
const WsDot = ({ status }: { status: typeof wsStatus }) => { const WsDot = ({ status, label }: { status: typeof metaWsStatus, label: string }) => {
const color = const color =
status === 'open' ? 'bg-green-500' : status === 'open' ? 'bg-green-500' :
status === 'connecting' ? 'bg-amber-500' : status === 'connecting' ? 'bg-amber-500' :
status === 'error' ? 'bg-red-500' : status === 'error' ? 'bg-red-500' :
'bg-neutral-400' 'bg-neutral-400'
const label = const txt =
status === 'open' ? 'verbunden' : status === 'open' ? 'verbunden' :
status === 'connecting' ? 'verbinde…' : status === 'connecting' ? 'verbinde…' :
status === 'error' ? 'Fehler' : status === 'error' ? 'Fehler' :
status === 'closed' ? 'getrennt' : '—' status === 'closed' ? 'getrennt' : '—'
return ( return (
<span className="inline-flex items-center gap-1 text-xs opacity-80"> <span className="inline-flex items-center gap-1 text-xs opacity-80">
<span className="font-medium">{label}</span>
<span className={`inline-block w-2.5 h-2.5 rounded-full ${color}`} /> <span className={`inline-block w-2.5 h-2.5 rounded-full ${color}`} />
{label} {txt}
</span> </span>
) )
} }
@ -513,18 +477,36 @@ export default function LiveRadar() {
/* ───────── Render ───────── */ /* ───────── Render ───────── */
return ( return (
<div className="p-4"> <div className="p-4">
{/* Head + WS-Badges */}
<div ref={headerRef} className="mb-4 flex items-center justify-between"> <div ref={headerRef} className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Live Radar</h2> <h2 className="text-xl font-semibold">Live Radar</h2>
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div className="text-sm opacity-80"> <div className="text-sm opacity-80">
{activeMapKey {activeMapKey ? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase() : '—'}
? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase()
: '—'}
</div> </div>
<WsDot status={wsStatus} /> <WsDot status={metaWsStatus} label="Meta" />
<WsDot status={posWsStatus} label="Pos" />
</div> </div>
</div> </div>
{/* Unsichtbare WS-Clients */}
<MetaSocket
url={metaUrl}
onStatus={setMetaWsStatus}
onMap={(k)=> setActiveMapKey(k.toLowerCase())}
onPlayersSnapshot={handleMetaPlayersSnapshot}
onPlayerJoin={handleMetaPlayerJoin}
onPlayerLeave={handleMetaPlayerLeave}
/>
<PositionsSocket
url={posUrl}
onStatus={setPosWsStatus}
onMap={(k)=> setActiveMapKey(String(k).toLowerCase())}
onPlayerUpdate={(p)=> { upsertPlayer(p); scheduleFlush() }}
onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }}
onGrenades={(g)=> { handleGrenades(g); scheduleFlush() }}
/>
{!activeMapKey ? ( {!activeMapKey ? (
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"> <div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
Keine Map erkannt. Keine Map erkannt.
@ -558,48 +540,32 @@ export default function LiveRadar() {
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`} viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
> >
{/* ───── Grenades layer (unter Spielern) ───── */} {/* Grenades */}
{grenades.map((g) => { {grenades.map((g) => {
const P = worldToPx(g.x, g.y) const P = worldToPx(g.x, g.y)
// typische Radien (world units), falls Server nichts liefert
const defaultRadius = const defaultRadius =
g.kind === 'smoke' ? 150 : g.kind === 'smoke' ? 150 :
g.kind === 'molotov'? 120 : g.kind === 'molotov'? 120 :
g.kind === 'he' ? 40 : g.kind === 'he' ? 40 :
g.kind === 'flash' ? 36 : g.kind === 'flash' ? 36 :
g.kind === 'decoy' ? 80 : 60 g.kind === 'decoy' ? 80 : 60
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defaultRadius)) const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defaultRadius))
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
: g.team === 'T' ? UI.nade.teamStrokeT : g.team === 'T' ? UI.nade.teamStrokeT
: UI.nade.stroke : UI.nade.stroke
const sw = Math.max(1, Math.sqrt(rPx) * 0.6) const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
if (g.kind === 'smoke') { if (g.kind === 'smoke') {
return ( return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
</g>
)
} }
if (g.kind === 'molotov') { if (g.kind === 'molotov') {
return ( return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
</g>
)
} }
if (g.kind === 'decoy') { if (g.kind === 'decoy') {
return ( return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
</g>
)
} }
if (g.kind === 'flash') { if (g.kind === 'flash') {
// kleiner Ring + Kreuz
return ( return (
<g key={g.id}> <g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} /> <circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
@ -609,29 +575,22 @@ export default function LiveRadar() {
</g> </g>
) )
} }
// HE + unknown: kompakter Punkt return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
return (
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
</g>
)
})} })}
{/* ───── Spieler layer ───── */} {/* Spieler */}
{players {players
.filter(p => p.team === 'CT' || p.team === 'T') .filter(p => p.team === 'CT' || p.team === 'T')
.map((p) => { .map((p) => {
const A = worldToPx(p.x, p.y) const A = worldToPx(p.x, p.y)
const base = Math.min(imgSize.w, imgSize.h) const base = Math.min(imgSize.w, imgSize.h)
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel) const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel) const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
const stroke = UI.player.stroke const stroke = UI.player.stroke
const strokeW = Math.max(1, r * UI.player.lineWidthRel) const strokeW = Math.max(1, r * UI.player.lineWidthRel)
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
// Blickrichtung aus yaw (Grad)
let dxp = 0, dyp = 0 let dxp = 0, dyp = 0
if (Number.isFinite(p.yaw as number)) { if (Number.isFinite(p.yaw as number)) {
const yawRad = (Number(p.yaw) * Math.PI) / 180 const yawRad = (Number(p.yaw) * Math.PI) / 180

View File

@ -0,0 +1,70 @@
// /app/components/MetaSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
type MetaSocketProps = {
url?: string
onStatus?: (s: Status) => void
onMap?: (mapKey: string) => void
onPlayersSnapshot?: (list: any[]) => void
onPlayerJoin?: (p: any) => void
onPlayerLeave?: (steamId: string | number) => void
}
export default function MetaSocket({
url,
onStatus,
onMap,
onPlayersSnapshot,
onPlayerJoin,
onPlayerLeave,
}: MetaSocketProps) {
const wsRef = useRef<WebSocket | null>(null)
const aliveRef = useRef(true)
const retryRef = useRef<number | null>(null)
useEffect(() => {
aliveRef.current = true
const connect = () => {
if (!aliveRef.current) return
onStatus?.('connecting')
const ws = new WebSocket(url!)
wsRef.current = ws
ws.onopen = () => onStatus?.('open')
ws.onerror = () => onStatus?.('error')
ws.onclose = () => {
onStatus?.('closed')
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return
// KEINE matchId-Filterung mehr
if (msg.type === 'map' && typeof msg.name === 'string') {
onMap?.(msg.name.toLowerCase())
} else if (msg.type === 'players' && Array.isArray(msg.players)) {
onPlayersSnapshot?.(msg.players)
} else if (msg.type === 'player_join' && msg.player) {
onPlayerJoin?.(msg.player)
} else if (msg.type === 'player_leave') {
onPlayerLeave?.(msg.steamId ?? msg.steam_id ?? msg.id)
}
}
}
if (url) connect()
return () => {
aliveRef.current = false
if (retryRef.current) window.clearTimeout(retryRef.current)
try { wsRef.current?.close(1000, 'meta unmounted') } catch {}
}
}, [url, onStatus, onMap, onPlayersSnapshot, onPlayerJoin, onPlayerLeave])
return null
}

View File

@ -0,0 +1,77 @@
// /app/components/PositionsSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
type PositionsSocketProps = {
url?: string
onStatus?: (s: Status) => void
onMap?: (mapKey: string) => void
onPlayerUpdate?: (p: any) => void
onPlayersAll?: (allplayers: any) => void
onGrenades?: (g: any) => void
}
export default function PositionsSocket({
url,
onStatus,
onMap,
onPlayerUpdate,
onPlayersAll,
onGrenades,
}: PositionsSocketProps) {
const wsRef = useRef<WebSocket | null>(null)
const aliveRef = useRef(true)
const retryRef = useRef<number | null>(null)
const dispatch = (msg: any) => {
if (!msg) return
// KEINE matchId-Filterung mehr
if (msg.type === 'tick') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase())
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}))
if (msg.grenades) onGrenades?.(msg.grenades)
return
}
if (msg.map && typeof msg.map.name === 'string') onMap?.(msg.map.name.toLowerCase())
if (msg.allplayers) onPlayersAll?.(msg)
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg)
if (msg.grenades && msg.type !== 'tick') onGrenades?.(msg.grenades)
}
useEffect(() => {
aliveRef.current = true
const connect = () => {
if (!aliveRef.current) return
onStatus?.('connecting')
const ws = new WebSocket(url!)
wsRef.current = ws
ws.onopen = () => onStatus?.('open')
ws.onerror = () => onStatus?.('error')
ws.onclose = () => {
onStatus?.('closed')
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (Array.isArray(msg)) msg.forEach(dispatch)
else dispatch(msg)
}
}
if (url) connect()
return () => {
aliveRef.current = false
if (retryRef.current) window.clearTimeout(retryRef.current)
try { wsRef.current?.close(1000, 'positions unmounted') } catch {}
}
}, [url, onStatus])
return null
}

View File

@ -23,12 +23,10 @@ export const SSE_EVENT_TYPES = [
'expired-sharecode', 'expired-sharecode',
'team-invite-revoked', 'team-invite-revoked',
'map-vote-updated', 'map-vote-updated',
'match-meta-updated',
'map-vote-admin-edit', 'map-vote-admin-edit',
'match-created', 'match-created',
'matches-updated', 'matches-updated',
'match-deleted', 'match-deleted',
'match-updated',
'match-lineup-updated', 'match-lineup-updated',
'user-status-updated', 'user-status-updated',
'match-ready', 'match-ready',
@ -82,7 +80,6 @@ export const MATCH_EVENTS = makeEventSet([
'matches-updated', 'matches-updated',
'match-deleted', 'match-deleted',
'match-lineup-updated', 'match-lineup-updated',
'match-updated',
'map-vote-updated', 'map-vote-updated',
'map-vote-admin-edit', 'map-vote-admin-edit',
'match-ready', 'match-ready',

View File

@ -1,5 +1,7 @@
import LiveRadar from '@/app/components/LiveRadar' // /app/match-details/[matchId]/radar/page.tsx
import LiveRadar from '@/app/components/radar/LiveRadar'
export default function RadarPage({ params }: { params: { matchId: string } }) { export default function RadarPage({ params }: { params: { matchId: string } }) {
return <LiveRadar matchId={params.matchId} /> return <LiveRadar />
} }

View File

@ -34,6 +34,7 @@ export type Match = {
status : 'not_started' | 'in_progress' | 'completed' | null status : 'not_started' | 'in_progress' | 'completed' | null
opensAt: string | null opensAt: string | null
isOpen : boolean | null isOpen : boolean | null
leadMinutes?: number | null
locked?: boolean | null locked?: boolean | null
steps? : MapVoteStep[] steps? : MapVoteStep[]
} | null } | null