updated
This commit is contained in:
parent
990c73beef
commit
e93c00154a
13
.env
13
.env
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
70
src/app/components/radar/MetaSocket.tsx
Normal file
70
src/app/components/radar/MetaSocket.tsx
Normal 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
|
||||
}
|
||||
77
src/app/components/radar/PositionsSocket.tsx
Normal file
77
src/app/components/radar/PositionsSocket.tsx
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user