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_PASSWORD=IJHoYHTXQvJkCxkycTYM
PTERO_SERVER_ID=37a11489
NEXT_PUBLIC_CS2_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_WS_PORT=443
NEXT_PUBLIC_CS2_WS_PATH=/telemetry
# 🌍 Meta-WebSocket (CS2 Server Plugin)
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
import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'

View File

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

View File

@ -1,5 +1,4 @@
// /app/components/MapVoteBanner.tsx
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
@ -35,18 +34,32 @@ function formatLead(minutes: number) {
return `${m}min`
}
export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes }: Props) {
export default function MapVoteBanner({
match,
initialNow,
matchBaseTs,
sseOpensAtTs,
sseLeadMinutes,
}: Props) {
const router = useRouter()
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
const [state, setState] = useState<MapVoteState | 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)
// 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)
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
const load = useCallback(async () => {
try {
@ -65,23 +78,16 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
}
}, [match.id])
// initial + bei Meta-Änderungen
useEffect(() => { load() }, [load])
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
const matchDateTs = useMemo(
() => (typeof matchBaseTs === 'number' ? matchBaseTs : null),
[matchBaseTs]
)
useEffect(() => {
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
// SSE: nur map-vote-updated & Co. beachten
useEffect(() => {
if (!lastEvent) return
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())
: undefined
// sofortige lokale Overrides, ohne auf fetch zu warten
// Sofort lokale Overrides setzen
if (nextOpensAtISO) {
setOpensAtOverride(new Date(nextOpensAtISO).getTime())
} 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)
// sichtbares Mergen (für UI-Text)
// sichtbares Mergen (für UI-Texte)
if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) {
setState(prev => ({
...(prev ?? {} as any),
@ -117,28 +123,23 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}),
}) as any)
} else {
// nur nachladen, wenn Event keine konkreten Werte trug
load()
}
}, [lastEvent, match.id, matchDateTs, load])
// Öffnet wann?
// Öffnet wann? (Priorität: Parent-SSE → lokale SSE → Server → Fallback)
const opensAt = useMemo(() => {
// höchste Priorität: vom Parent (MatchDetails) gereichter TS
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs
// dann lokaler SSE-Override
if (opensAtOverride != null) return opensAtOverride
// dann Serverwert aus /mapvote
if (state?.opensAt) return new Date(state.opensAt).getTime()
// Fallback aus Basis + Lead
if (matchDateTs == null) return new Date(initialNow).getTime()
const lead = (typeof sseLeadMinutes === 'number')
? 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
}, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes])
// Text „startet X vor Matchbeginn“
// „startet X vor Matchbeginn“
const leadMinutes = useMemo(() => {
if (matchDateTs != null && opensAt != null) {
return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000))
@ -149,16 +150,16 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
return 60
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes])
const isOpen = now >= opensAt
const isOpen = mounted && now >= opensAt
const msToOpen = Math.max(opensAt - now, 0)
const current = state?.steps?.[state.currentIndex]
const current = state?.steps?.[state?.currentIndex ?? 0]
const whoIsUp = current?.teamId
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
: null
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 isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session?.user?.steamId
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session?.user?.steamId
const isAdmin = !!session?.user?.isAdmin
const iCanAct = Boolean(
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 ' +
(isOpen
? '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 (
<div
@ -232,8 +233,12 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
</span>
) : (
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100">
Öffnet in {formatCountdown(msToOpen)}
// 🔑 Hydration-safe: vor dem Mount nur ein Placeholder rendern
<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>
)}
</div>
@ -254,7 +259,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
);
background-size: 200% 100%;
background-repeat: repeat-x;
animation: slide-x 6s linear infinite; /* etwas ruhiger */
animation: slide-x 6s linear infinite;
}
:global(.dark) .mapVoteGradient {
background-image: repeating-linear-gradient(
@ -290,12 +295,10 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
transform: translateX(-120%) skewX(-20deg);
transition: opacity .2s;
}
/* nur wenn die Karte offen ist und gehovert wird */
:global(.group:hover) .shine::before {
animation: shine 3.8s ease-out infinite;
}
/* Respektiere Bewegungs-Präferenzen */
@media (prefers-reduced-motion: reduce) {
.mapVoteGradient { animation: none; }
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }

View File

@ -1,3 +1,5 @@
// MapVotePanel.tsx
'use client'
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 [adminEditMode, setAdminEditMode] = useState(false)
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
/* -------- Timers / open window -------- */
const opensAtTs = useMemo(() => {
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
return base.getTime() - 60 * 60 * 1000
// ⚠️ Wichtig: hier keine Date.now()-abhängigen Ausgaben im SSR!
// 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])
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(() => {
const t = setInterval(() => setNowTs(Date.now()), 1000)
return () => clearInterval(t)
}, [])
if (!mounted) return
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 -------- */
const overlayIsForThisMatch = overlayData?.matchId === match.id
// Merken: Overlay wurde für dieses Match mindestens einmal angezeigt
useEffect(() => {
if (overlayOpen && overlayIsForThisMatch) setOverlayShownOnce(true)
}, [overlayOpen, overlayIsForThisMatch])
@ -109,12 +150,38 @@ export default function MapVotePanel({ match }: Props) {
useEffect(() => { load() }, [load])
// 🔔 Reagiere auf alle relevanten Match-Events (inkl. match-updated)
useEffect(() => {
if (!lastEvent) return
if (!MATCH_EVENTS.has(lastEvent.type)) return
if (lastEvent.payload?.matchId !== match.id) return
load()
}, [lastEvent, match.id, load])
// robustes Unwrapping (doppelte payload)
const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e
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 -------- */
const adminEditingBy = state?.adminEdit?.by ?? null
@ -125,14 +192,6 @@ export default function MapVotePanel({ match }: Props) {
}, [adminEditingEnabled, adminEditingBy, session?.user?.steamId])
/* -------- 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 isAdmin = !!me?.isAdmin
const mySteamId = me?.steamId
@ -278,8 +337,7 @@ export default function MapVotePanel({ match }: Props) {
}
if (holdMapRef.current === map) {
resetHold()
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
}
setProgressByMap(prev => ({ ...prev, [map]: 0 }))}
}, [progressByMap, resetHold, finishAndSubmit])
const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => {
@ -495,7 +553,7 @@ export default function MapVotePanel({ match }: Props) {
)}
</span>
) : 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">
🔒 Admin-Edit aktiv Voting pausiert
{(() => {
@ -519,8 +577,11 @@ export default function MapVotePanel({ match }: Props) {
</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">
Öffnet in {fmtCountdown(msToOpen)}
<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"
suppressHydrationWarning
>
Öffnet in {mounted && openTs != null ? fmtCountdown(msToOpen) : '::'}
</span>
)}
</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">
{/* Linke Spalte */}
<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">
<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="font-bold text-lg truncate">{teamLeft?.name ?? 'Team'}</div>
<div className="font-bold text-lg truncate">{match.teamA?.name ?? 'Team'}</div>
</div>
<TeamPremierRankBadge players={rankLeft} />
<TeamPremierRankBadge players={playersA.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any} />
</div>
{playersLeft.map((p: MatchPlayer) => (
{playersA.map((p: MatchPlayer) => (
<MapVoteProfileCard
key={p.user.steamId}
steamId={p.user.steamId}
@ -561,8 +626,8 @@ export default function MapVotePanel({ match }: Props) {
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId}
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id) && !state.locked}
isLeader={(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) === p.user.steamId}
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) && !state.locked}
/>
))}
</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 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 =
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === teamLeft?.id
const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === teamRight?.id
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === match.teamA?.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 showProgress = isAvailable && progress > 0 && progress < 1
const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg`
const disabledTitle = isFrozenByAdmin ? 'Ein Admin bearbeitet gerade Voting gesperrt' : 'Nur der Team-Leader (oder Admin) darf wählen'
const disabledTitle = isFrozenByAdmin
? 'Ein Admin bearbeitet gerade Voting gesperrt'
: 'Nur der Team-Leader (oder Admin) darf wählen'
return (
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
{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" />}
<Button
@ -628,9 +708,9 @@ export default function MapVotePanel({ match }: Props) {
onMouseDown={() => onHoldStart(map, isAvailable)}
onMouseUp={() => cancelOrSubmitIfComplete(map)}
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
onTouchStart={onTouchStart(map, isAvailable)}
onTouchEnd={onTouchEnd(map)}
onTouchCancel={onTouchEnd(map)}
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(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}')` }} />
{showProgress && (
@ -639,16 +719,16 @@ export default function MapVotePanel({ match }: Props) {
{taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
<>
{(((status === 'ban' && teamId === teamLeft?.id) ||
(status === 'pick' && effectiveTeamId === teamLeft?.id) ||
(status === 'decider' && effectiveTeamId === teamLeft?.id))) && (
{(((status === 'ban' && teamId === match.teamA?.id) ||
(status === 'pick' && effectiveTeamId === match.teamA?.id) ||
(status === 'decider' && effectiveTeamId === match.teamA?.id))) && (
<span className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded transition duration-300 ease-out ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
{status === 'ban' ? 'Ban' : 'Pick'}
</span>
)}
{(((status === 'ban' && teamId === teamRight?.id) ||
(status === 'pick' && effectiveTeamId === teamRight?.id) ||
(status === 'decider' && effectiveTeamId === teamRight?.id))) && (
{(((status === 'ban' && teamId === match.teamB?.id) ||
(status === 'pick' && effectiveTeamId === match.teamB?.id) ||
(status === 'decider' && effectiveTeamId === match.teamB?.id))) && (
<span className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
{status === 'ban' ? 'Ban' : 'Pick'}
</span>
@ -669,7 +749,7 @@ export default function MapVotePanel({ match }: Props) {
</Button>
{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" />}
</li>
)
@ -679,17 +759,21 @@ export default function MapVotePanel({ match }: Props) {
{/* Rechte Spalte */}
<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">
<TeamPremierRankBadge players={rankRight} />
<TeamPremierRankBadge players={playersB.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any} />
<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>
<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>
{playersRight.map((p: MatchPlayer) => (
{playersB.map((p: MatchPlayer) => (
<MapVoteProfileCard
key={p.user.steamId}
steamId={p.user.steamId}
@ -699,8 +783,8 @@ export default function MapVotePanel({ match }: Props) {
rank={p.stats?.rankNew ?? 0}
matchType={match.matchType}
onClick={() => router.push(`/profile/${p.user.steamId}`)}
isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId}
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamRightKey]?.id ?? teamRight?.id) && !state.locked}
isLeader={(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === p.user.steamId}
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) && !state.locked}
/>
))}
</div>
@ -717,16 +801,19 @@ export default function MapVotePanel({ match }: Props) {
const chosenSteps = (state.steps ?? []).filter(
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
if (decIdx >= 0) {
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 }
}
}
const teamLeftLogo = getTeamLogo(teamLeft?.logo)
const teamRightLogo = getTeamLogo(teamRight?.logo)
const teamLeftLogo = getTeamLogo(match.teamA?.logo)
const teamRightLogo = getTeamLogo(match.teamB?.logo)
return (
<AnimatePresence mode="popLayout">
@ -740,8 +827,8 @@ export default function MapVotePanel({ match }: Props) {
const pickTeamId = action === 'pick' ? (step?.teamId ?? null)
: action === 'decider' ? chooserTeamId
: null
const pickedByLeft = pickTeamId && pickTeamId === teamLeft?.id
const pickedByRight = pickTeamId && pickTeamId === teamRight?.id
const pickedByLeft = pickTeamId && pickTeamId === match.teamA?.id
const pickedByRight = pickTeamId && pickTeamId === match.teamB?.id
const sideLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null
const frameClasses =
action === 'pick' ? 'ring-2 ring-green-500'

View File

@ -1,8 +1,7 @@
// /app/components/MatchDetails.tsx
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
@ -23,7 +22,6 @@ import { useSSEStore } from '@/app/lib/useSSEStore'
import { Team } from '../types/team'
import Alert from './Alert'
import Image from 'next/image'
import { MATCH_EVENTS } from '../lib/sseEvents'
import Link from 'next/link'
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
@ -50,8 +48,7 @@ type VoteStep = { order: number; action: VoteAction; map?: string | null }
const mapLabelFromKey = (key?: string) => {
const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'')
return (
MAP_OPTIONS.find(o => o.key === k)?.label ??
(k ? k : 'TBD')
MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD')
)
}
@ -62,7 +59,6 @@ function extractSeriesMaps(match: Match): string[] {
.filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER'))
.sort((a,b) => (a.order ?? 0) - (b.order ?? 0))
.map(s => s.map ?? '')
// auf bestOf begrenzen
const n = Math.max(1, match.bestOf ?? 1)
return picks.slice(0, n)
}
@ -83,13 +79,11 @@ function SeriesStrip({
const needed = Math.ceil(bestOf / 2)
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 currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1)
return (
<div className="w-full">
{/* Kopfzeile der Serie */}
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-400">
Best of {bestOf} First to {needed}
@ -99,14 +93,11 @@ function SeriesStrip({
</div>
</div>
{/* Kartenleiste */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{Array.from({ length: total }).map((_, i) => {
const key = maps[i] ?? ''
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 isCurrent = i === currentIdx
const isFuture = i > winsA + winsB
@ -144,18 +135,22 @@ function SeriesStrip({
)
}
/* ─────────────────── Komponente ─────────────────────────────── */
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
const router = useRouter()
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 [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 ─────────────────────────────────────── */
const me = session?.user
@ -168,14 +163,14 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
/* ─── Map ─────────────────────────────────────────────────── */
/* ─── Map-Label ───────────────────────────────────────────── */
const mapKey = normalizeMapKey(match.map)
const mapLabel =
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ??
'Unbekannte Map'
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
/* ─── Match-Zeitpunkt (vom Server; ändert sich via router.refresh) ─── */
const dateString = match.matchDate ?? match.demoDate
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 }, () => '')]
}, [match.bestOf, match.mapVote?.steps?.length])
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
/* ─── Modal-State ─────────────────────────────────────────── */
const [editSide, setEditSide] = useState<EditSide | null>(null)
/* ─── Live-Uhr (für vote-Zeitpunkt) ───────────────────────── */
/* ─── Live-Uhr für Mapvote-Startfenster ───────────────────── */
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
// Basiszeit des Matches einmal berechnen
// Basiszeit des Matches (stabil; für Berechnung von opensAt-Fallback)
const matchBaseTs = useMemo(() => {
const raw = match.matchDate ?? match.demoDate ?? initialNow;
return new Date(raw).getTime();
}, [match.matchDate, match.demoDate, initialNow]);
const raw = match.matchDate ?? match.demoDate ?? initialNow
return new Date(raw).getTime()
}, [match.matchDate, match.demoDate, initialNow])
// Zeitpunkt, wann der Mapvote öffnet (Parent errechnet und an Banner gereicht)
const voteOpensAtTs = useMemo(() => {
if (opensAtOverride != null) return opensAtOverride; // SSE hat Vorrang
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime(); // vom Server
const lead = (leadOverride != null) ? leadOverride : 60; // kein 60-min Zwang
return matchBaseTs - lead * 60_000;
}, [opensAtOverride, match.mapVote?.opensAt, matchBaseTs, leadOverride]);
if (opensAtOverride != null) return opensAtOverride
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime()
const lead = (leadOverride != null)
? leadOverride
: (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
return matchBaseTs - lead * 60_000
}, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride])
const sseOpensAtTs = voteOpensAtTs;
const sseLeadMinutes = leadOverride;
const sseOpensAtTs = voteOpensAtTs
const sseLeadMinutes = leadOverride
const endDate = new Date(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 showEditB = canEditB && !mapvoteStarted
/* ─── SSE-Listener ─────────────────────────────────────────── */
/* ─── SSE-Listener (nur map-vote-updated & Co.) ───────────── */
useEffect(() => {
if (!lastEvent) return;
const evt = (lastEvent as any).payload ?? lastEvent;
if (evt?.matchId !== match.id) return;
if (!lastEvent) return
if (lastEvent.type === 'map-vote-updated') {
// opensAt aus Event übernehmen
// robustes Unwrap
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) {
const ts = typeof evt.opensAt === 'string'
? new Date(evt.opensAt).getTime()
: new Date(evt.opensAt).getTime();
setOpensAtOverride(ts);
const ts = new Date(evt.opensAt).getTime()
setOpensAtOverride(ts)
}
// leadMinutes mitschneiden u. ggf. opensAt daraus ableiten
if (Number.isFinite(evt?.leadMinutes)) {
const lead = Number(evt.leadMinutes);
setLeadOverride(lead);
const lead = Number(evt.leadMinutes)
setLeadOverride(lead)
if (!evt?.opensAt) {
const base = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime();
setOpensAtOverride(base - lead * 60_000);
const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
setOpensAtOverride(baseTs - lead * 60_000)
}
}
// damit match.matchDate & Co. neu vom Server kommen
router.refresh()
return
}
const REFRESH_TYPES = new Set([
'map-vote-reset','map-vote-locked','map-vote-unlocked',
'match-updated','match-lineup-updated',
]);
if (REFRESH_TYPES.has(lastEvent.type) && evt?.matchId === match.id) {
router.refresh();
const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
if (REFRESH_TYPES.has(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) ───────────────────────── */
const ColGroup = () => (
@ -292,21 +302,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Table.Head>
<Table.Row>
{[
'Spieler',
'Rank',
'Aim',
'K',
'A',
'D',
'1K',
'2K',
'3K',
'4K',
'5K',
'K/D',
'ADR',
'HS%',
'Damage',
'Spieler', 'Rank', 'Aim', 'K', 'A', 'D',
'1K', '2K', '3K', '4K', '5K',
'K/D', 'ADR', 'HS%', 'Damage',
].map((h) => (
<Table.Cell key={h} as="th">
{h}
@ -333,11 +331,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Table.Cell>
<div className="flex items-center gap-[6px]">
{match.matchType === 'premier' ? (
<PremierRankBadge rank={p.stats?.rankNew ?? 0} />
) : (
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
)}
{match.matchType === 'premier'
? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
: <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span
className={`text-sm ${
@ -384,14 +380,12 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<div className="space-y-6">
{/* Kopfzeile: Zurück + Admin-Buttons */}
<div className="flex items-center justify-between">
{/* Links: Zurück */}
<Link href="/schedule">
<Button color="gray" variant="outline">
Zurück
</Button>
</Link>
{/* Rechts: Admin-Buttons */}
{isAdmin && (
<div className="flex gap-2">
<Button
@ -414,6 +408,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
Match auf {mapLabel} ({match.matchType})
</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>
{(match.bestOf ?? 1) > 1 && (
@ -422,7 +417,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
bestOf={match.bestOf ?? 3}
scoreA={match.scoreA}
scoreB={match.scoreB}
maps={seriesMaps}
maps={extractSeriesMaps(match)}
/>
</div>
)}
@ -435,6 +430,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div>
{/* MapVote-Banner erhält die aktuell berechneten (SSE-konformen) Werte */}
<MapVoteBanner
match={match}
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">
<div className="flex items-center gap-2">
{showEditA ? (
<>
{/* Unlocked-Icon */}
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<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>
</>
) : (
<>
{/* 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>
</>
)
}
{canEditA && !mapvoteStarted ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<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>
)}
<span className='text-gray-300'>
{showEditA ? (
{canEditA && !mapvoteStarted ? (
<>
Du kannst die Aufstellung noch bis{' '}
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
@ -483,10 +472,10 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Button
size="sm"
onClick={() => showEditA && setEditSide('A')}
disabled={!showEditA}
onClick={() => (canEditA && !mapvoteStarted) && setEditSide('A')}
disabled={!(canEditA && !mapvoteStarted)}
className={`px-3 py-1.5 text-sm rounded-lg ${
showEditA
canEditA && !mapvoteStarted
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
}`}
@ -506,11 +495,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{match.teamB?.logo && (
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
<Image
src={
match.teamB.logo
? `/assets/img/logos/${match.teamB.logo}`
: `/assets/img/logos/cs2.webp`
}
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt="Teamlogo"
fill
sizes="64px"
@ -522,28 +507,20 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
{match.teamB?.name ?? 'Team B'}
</h2>
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
{showEditB ? (
<>
{/* Unlocked-Icon */}
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<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>
</>
) : (
<>
{/* 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>
</>
)
}
{canEditB && !mapvoteStarted ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<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>
)}
<span className='text-gray-300'>
{showEditB ? (
{canEditB && !mapvoteStarted ? (
<>
Du kannst die Aufstellung noch bis{' '}
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
@ -556,10 +533,10 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
<Button
size="sm"
onClick={() => showEditB && setEditSide('B')}
disabled={!showEditB}
onClick={() => (canEditB && !mapvoteStarted) && setEditSide('B')}
disabled={!(canEditB && !mapvoteStarted)}
className={`px-3 py-1.5 text-sm rounded-lg ${
showEditB
canEditB && !mapvoteStarted
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
}`}
@ -584,7 +561,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
side={editSide}
initialA={teamAPlayers.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}
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null}
defaultVoteLeadMinutes={60}
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
/>
)}

View File

@ -1,23 +1,24 @@
// /app/components/LiveRadar.tsx
// /app/components/radar/LiveRadar.tsx
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import MetaSocket from './MetaSocket'
import PositionsSocket from './PositionsSocket'
/* ───────────────── UI ───────────────── */
/* ───────── UI config ───────── */
const UI = {
player: {
minRadiusPx: 4,
radiusRel: 0.008, // relativ zur kleineren Bildkante
dirLenRel: 0.70, // Anteil des Radius
radiusRel: 0.008,
dirLenRel: 0.70,
dirMinLenPx: 6,
lineWidthRel: 0.25,
stroke: '#ffffff',
fillCT: '#3b82f6',
fillT: '#f59e0b',
dirColor: 'auto' as 'auto' | string, // 'auto' = Kontrast zum Kreis
dirColor: 'auto' as 'auto' | string,
},
/* ───────────────── UI (Grenades) ───────────────── */
nade: {
stroke: '#111111',
smokeFill: 'rgba(160,160,160,0.35)',
@ -27,10 +28,11 @@ const UI = {
decoyFill: 'rgba(140,140,255,0.25)',
teamStrokeCT: '#3b82f6',
teamStrokeT: '#f59e0b',
minRadiusPx: 6
}
minRadiusPx: 6,
},
}
/* ───────── helpers ───────── */
function contrastStroke(hex: string) {
const h = hex.replace('#','')
const r = parseInt(h.slice(0,2),16)/255
@ -47,25 +49,31 @@ function mapTeam(t: any): 'T' | 'CT' | string {
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) {
d = d % 360;
return d < 0 ? d + 360 : d;
// Heuristik: wenn explizit 443 -> wss, wenn Seite https und Host != localhost -> wss, sonst ws
const isLocal = ['127.0.0.1', 'localhost', '::1'].includes(host)
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 = {
id: string
name?: string | null
@ -73,10 +81,9 @@ type PlayerState = {
x: number
y: number
z: number
yaw?: number | null // Grad
yaw?: number | null
alive?: boolean
}
type Grenade = {
id: string
kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown'
@ -87,24 +94,22 @@ type Grenade = {
expiresAt?: number | null
team?: 'T' | 'CT' | string | null
}
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
type Mapper = (xw: number, yw: number) => { x: number; y: number }
/* ───────────────── Komponente ───────────────── */
/* ───────── Komponente ───────── */
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)
// Spieler (throttled)
const playersRef = useRef<Map<string, PlayerState>>(new Map())
const [players, setPlayers] = useState<PlayerState[]>([])
// Grenades (throttled)
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
const [grenades, setGrenades] = useState<Grenade[]>([])
// gemeinsamer Flush (Players + Grenades)
const flushTimer = useRef<number | null>(null)
const scheduleFlush = () => {
if (flushTimer.current != null) return
@ -115,232 +120,192 @@ export default function LiveRadar() {
}, 66)
}
/* ───────────── WebSocket ───────────── */
useEffect(() => {
if (typeof window === 'undefined') return
const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL
const host = process.env.NEXT_PUBLIC_CS2_WS_HOST || window.location.hostname
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,
})
return () => {
if (flushTimer.current != null) {
window.clearTimeout(flushTimer.current)
flushTimer.current = null
}
}
}, [])
// 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 s = mapTeam(t)
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null)
}
const normalizeGrenades = (raw: any): Grenade[] => {
if (!raw) return []
// 1) Falls schon Array [{type, pos{x,y,z}, ...}]
if (Array.isArray(raw)) {
return raw.map((g: any, i: number) => {
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
return {
id: String(g.id ?? `${g.type ?? 'nade'}#${i}`),
kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']),
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)
} as Grenade
if (Array.isArray(raw)) {
return raw.map((g: any, i: number) => {
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
return {
id: String(g.id ?? `${g.type ?? 'nade'}#${i}`),
kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']),
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[]> = {
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
}
const ingestGrenades = (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
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])
}
const dispatch = (m: any) => {
if (!m) return
// Map aus verschiedenen Formaten abgreifen
if (m.type === 'map' || m.type === 'level' || m.map) {
const key = m.name || m.map || m.level || m.map?.name
if (typeof key === 'string' && key) setActiveMapKey(key.toLowerCase())
}
// GSI Zuschauer-Format
if (m.allplayers) handleAllPlayers(m)
// 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)
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
}
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()
return () => {
alive = false
if (retry) window.clearTimeout(retry)
try { ws?.close(1000, 'radar unmounted') } catch {}
}
// gemeinsamer flush bei Positionsdaten
useEffect(() => {
if (!playersRef.current && !grenadesRef.current) return
scheduleFlush()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/* ───────────── Overview laden ───────────── */
/* ───────── Overview + Radarbild ───────── */
const [overview, setOverview] = useState<Overview | null>(null)
const overviewCandidates = (mapKey: string) => {
const base = mapKey
@ -387,7 +352,6 @@ export default function LiveRadar() {
return () => { cancel = true }
}, [activeMapKey])
/* ───────────── Radarbild ───────────── */
const { folderKey, imageCandidates } = useMemo(() => {
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
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 w = imgSize.w, h = imgSize.h
const cx = w / 2, cy = h / 2
const bases: ((xw: number, yw: number) => { x: number; y: number })[] = [
(xw, yw) => ({ x: (xw - posX) / 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(() => {
if (!imgSize) return (u: number) => u
if (overview) {
const scale = overview.scale // world units per pixel
const scale = overview.scale
return (u: number) => u / scale
}
const R = 4096
@ -491,21 +454,22 @@ export default function LiveRadar() {
}, [imgSize, overview])
/* ───────── Status-Badge ───────── */
const WsDot = ({ status }: { status: typeof wsStatus }) => {
const WsDot = ({ status, label }: { status: typeof metaWsStatus, label: string }) => {
const color =
status === 'open' ? 'bg-green-500' :
status === 'connecting' ? 'bg-amber-500' :
status === 'error' ? 'bg-red-500' :
'bg-neutral-400'
const label =
const txt =
status === 'open' ? 'verbunden' :
status === 'connecting' ? 'verbinde…' :
status === 'error' ? 'Fehler' :
status === 'closed' ? 'getrennt' : '—'
return (
<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}`} />
{label}
{txt}
</span>
)
}
@ -513,18 +477,36 @@ export default function LiveRadar() {
/* ───────── Render ───────── */
return (
<div className="p-4">
{/* Head + WS-Badges */}
<div ref={headerRef} className="mb-4 flex items-center justify-between">
<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">
{activeMapKey
? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase()
: '—'}
{activeMapKey ? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase() : '—'}
</div>
<WsDot status={wsStatus} />
<WsDot status={metaWsStatus} label="Meta" />
<WsDot status={posWsStatus} label="Pos" />
</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 ? (
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
Keine Map erkannt.
@ -558,48 +540,32 @@ export default function LiveRadar() {
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid meet"
>
{/* ───── Grenades layer (unter Spielern) ───── */}
{/* Grenades */}
{grenades.map((g) => {
const P = worldToPx(g.x, g.y)
// typische Radien (world units), falls Server nichts liefert
const defaultRadius =
g.kind === 'smoke' ? 150 :
g.kind === 'molotov'? 120 :
g.kind === 'he' ? 40 :
g.kind === 'flash' ? 36 :
g.kind === 'decoy' ? 80 : 60
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defaultRadius))
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
: g.team === 'T' ? UI.nade.teamStrokeT
: UI.nade.stroke
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
if (g.kind === 'smoke') {
return (
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
</g>
)
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
}
if (g.kind === 'molotov') {
return (
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
</g>
)
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
}
if (g.kind === 'decoy') {
return (
<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>
)
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
}
if (g.kind === 'flash') {
// kleiner Ring + Kreuz
return (
<g key={g.id}>
<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>
)
}
// HE + unknown: kompakter Punkt
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>
)
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)} />
})}
{/* ───── Spieler layer ───── */}
{/* Spieler */}
{players
.filter(p => p.team === 'CT' || p.team === 'T')
.map((p) => {
const A = worldToPx(p.x, p.y)
const base = Math.min(imgSize.w, imgSize.h)
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
const stroke = UI.player.stroke
const strokeW = Math.max(1, r * UI.player.lineWidthRel)
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
// Blickrichtung aus yaw (Grad)
let dxp = 0, dyp = 0
if (Number.isFinite(p.yaw as number)) {
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',
'team-invite-revoked',
'map-vote-updated',
'match-meta-updated',
'map-vote-admin-edit',
'match-created',
'matches-updated',
'match-deleted',
'match-updated',
'match-lineup-updated',
'user-status-updated',
'match-ready',
@ -82,7 +80,6 @@ export const MATCH_EVENTS = makeEventSet([
'matches-updated',
'match-deleted',
'match-lineup-updated',
'match-updated',
'map-vote-updated',
'map-vote-admin-edit',
'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 } }) {
return <LiveRadar matchId={params.matchId} />
return <LiveRadar />
}

View File

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