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_USER=army.37a11489
|
||||||
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
||||||
PTERO_SERVER_ID=37a11489
|
PTERO_SERVER_ID=37a11489
|
||||||
NEXT_PUBLIC_CS2_WS_HOST=ironieopen.local
|
|
||||||
NEXT_PUBLIC_CS2_WS_PORT=443
|
# 🌍 Meta-WebSocket (CS2 Server Plugin)
|
||||||
NEXT_PUBLIC_CS2_WS_PATH=/telemetry
|
NEXT_PUBLIC_CS2_META_WS_HOST=cs2.ironieopen.de
|
||||||
|
NEXT_PUBLIC_CS2_META_WS_PORT=443
|
||||||
|
NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry
|
||||||
|
|
||||||
|
# 🖥️ Positionen / GSI-WebSocket (lokaler Aggregator)
|
||||||
|
NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local
|
||||||
|
NEXT_PUBLIC_CS2_POS_WS_PORT=8082
|
||||||
|
NEXT_PUBLIC_CS2_POS_WS_PATH=/positions
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
// /app/api/matches/[id]/mapvote/route.ts
|
// /app/api/matches/[id]/mapvote/route.ts
|
||||||
|
|
||||||
import { NextResponse, NextRequest } from 'next/server'
|
import { NextResponse, NextRequest } from 'next/server'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/app/lib/auth'
|
import { authOptions } from '@/app/lib/auth'
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
// /app/api/matches/[matchId]/meta/route.ts
|
// /app/api/matches/[matchId]/meta/route.ts
|
||||||
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/app/lib/prisma'
|
import { prisma } from '@/app/lib/prisma'
|
||||||
import { getServerSession } from 'next-auth'
|
import { getServerSession } from 'next-auth'
|
||||||
import { authOptions } from '@/app/lib/auth'
|
import { authOptions } from '@/app/lib/auth'
|
||||||
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
|
||||||
|
|
||||||
export const runtime = 'nodejs' // 👈 wie bei mapvote
|
export const runtime = 'nodejs'
|
||||||
export const dynamic = 'force-dynamic' // 👈 wie bei mapvote
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
// Hilfsfunktion: akzeptiert Date | string | number | null | undefined
|
// Hilfsfunktion: akzeptiert Date | string | number | null | undefined
|
||||||
function parseDateOrNull(v: unknown): Date | null | undefined {
|
function parseDateOrNull(v: unknown): Date | null | undefined {
|
||||||
@ -44,7 +45,7 @@ function parseDateOrNull(v: unknown): Date | null | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// wie in mapvote: Basiszeit -> opensAt
|
// Basiszeit -> opensAt
|
||||||
function voteOpensAt(base: Date, leadMinutes: number) {
|
function voteOpensAt(base: Date, leadMinutes: number) {
|
||||||
return new Date(base.getTime() - leadMinutes * 60_000)
|
return new Date(base.getTime() - leadMinutes * 60_000)
|
||||||
}
|
}
|
||||||
@ -90,7 +91,7 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Matching-Daten zusammenbauen
|
// 1) Match-Felder zusammenbauen
|
||||||
const updateData: any = {}
|
const updateData: any = {}
|
||||||
if (typeof title !== 'undefined') updateData.title = title
|
if (typeof title !== 'undefined') updateData.title = title
|
||||||
if (typeof matchType === 'string') updateData.matchType = matchType
|
if (typeof matchType === 'string') updateData.matchType = matchType
|
||||||
@ -105,14 +106,14 @@ export async function PUT(
|
|||||||
if (parsedDemoDate !== undefined) {
|
if (parsedDemoDate !== undefined) {
|
||||||
updateData.demoDate = parsedDemoDate
|
updateData.demoDate = parsedDemoDate
|
||||||
} else if (parsedMatchDate instanceof Date) {
|
} else if (parsedMatchDate instanceof Date) {
|
||||||
// demoDate mitziehen, wenn matchDate geändert und demoDate nicht gesendet wurde
|
// demoDate mitschieben, wenn matchDate geändert und demoDate nicht gesendet wurde
|
||||||
updateData.demoDate = parsedMatchDate
|
updateData.demoDate = parsedMatchDate
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Lead bestimmen (Body > gespeicherter Wert > default 60)
|
// 2) Lead bestimmen (Body > gespeicherter Wert > default 60)
|
||||||
const leadBodyRaw = Number(voteLeadMinutes)
|
const leadBodyRaw = Number(voteLeadMinutes)
|
||||||
const leadBody = Number.isFinite(leadBodyRaw) ? leadBodyRaw : undefined
|
const leadBody = Number.isFinite(leadBodyRaw) ? leadBodyRaw : undefined
|
||||||
const currentLead = match.mapVote?.leadMinutes ?? 60 // erfordert Feld im Schema
|
const currentLead = match.mapVote?.leadMinutes ?? 60
|
||||||
const leadMinutes = leadBody ?? currentLead
|
const leadMinutes = leadBody ?? currentLead
|
||||||
|
|
||||||
// 3) Basiszeit (neu oder alt)
|
// 3) Basiszeit (neu oder alt)
|
||||||
@ -121,7 +122,7 @@ export async function PUT(
|
|||||||
(match.matchDate ?? null) ??
|
(match.matchDate ?? null) ??
|
||||||
(match.demoDate ?? null)
|
(match.demoDate ?? null)
|
||||||
|
|
||||||
// 4) Updaten & opensAt ggf. neu setzen – analog zu mapvote
|
// 4) Updaten & opensAt ggf. neu setzen
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
const m = await tx.match.update({
|
const m = await tx.match.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@ -129,16 +130,13 @@ export async function PUT(
|
|||||||
include: { mapVote: true },
|
include: { mapVote: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wenn wir eine Basiszeit haben → opensAt neu berechnen
|
|
||||||
if (baseDate) {
|
if (baseDate) {
|
||||||
const opensAt = voteOpensAt(baseDate, leadMinutes)
|
const opensAt = voteOpensAt(baseDate, leadMinutes)
|
||||||
|
|
||||||
if (!m.mapVote) {
|
if (!m.mapVote) {
|
||||||
// MapVote existiert noch nicht → nur opensAt/leadMinutes anlegen (Schritte erstellt get /mapvote)
|
|
||||||
await tx.mapVote.create({
|
await tx.mapVote.create({
|
||||||
data: {
|
data: {
|
||||||
matchId: m.id,
|
matchId: m.id,
|
||||||
leadMinutes: leadMinutes,
|
leadMinutes,
|
||||||
opensAt,
|
opensAt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -146,16 +144,16 @@ export async function PUT(
|
|||||||
await tx.mapVote.update({
|
await tx.mapVote.update({
|
||||||
where: { id: m.mapVote.id },
|
where: { id: m.mapVote.id },
|
||||||
data: {
|
data: {
|
||||||
...(leadBody !== undefined ? { leadMinutes: leadMinutes } : {}),
|
...(leadBody !== undefined ? { leadMinutes } : {}),
|
||||||
opensAt,
|
opensAt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (leadBody !== undefined && m.mapVote) {
|
} else if (leadBody !== undefined && m.mapVote) {
|
||||||
// Keine Basiszeit-Änderung, aber Lead explizit gesetzt → nur leadMinutes persistieren
|
// Nur Lead geändert
|
||||||
await tx.mapVote.update({
|
await tx.mapVote.update({
|
||||||
where: { id: m.mapVote.id },
|
where: { id: m.mapVote.id },
|
||||||
data: { leadMinutes: leadMinutes },
|
data: { leadMinutes },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,32 +169,19 @@ export async function PUT(
|
|||||||
|
|
||||||
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
|
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
|
||||||
|
|
||||||
// 5) Events senden – Shape identisch zur mapvote-Route
|
// Immer map-vote-updated senden, wenn es einen MapVote gibt
|
||||||
// a) Für das Voting/Countdown: map-vote-updated (liefert opensAt)
|
if (updated.mapVote) {
|
||||||
await sendServerSSEMessage({
|
await sendServerSSEMessage({
|
||||||
type: 'map-vote-updated',
|
type: 'map-vote-updated',
|
||||||
payload: {
|
payload: {
|
||||||
matchId: updated.id,
|
matchId: updated.id,
|
||||||
opensAt: updated.mapVote?.opensAt ?? null, // JSON.stringify -> ISO
|
leadMinutes: updated.mapVote.leadMinutes,
|
||||||
},
|
...(updated.mapVote.opensAt ? { opensAt: updated.mapVote.opensAt } : {}),
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// b) Zusätzlich Meta-Event für andere UIs
|
// 6) Response
|
||||||
await sendServerSSEMessage({
|
|
||||||
type: 'match-meta-updated',
|
|
||||||
payload: {
|
|
||||||
matchId: updated.id,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// (Optional) allgemeines match-updated, falls du andere Bereiche triggern willst
|
|
||||||
await sendServerSSEMessage({
|
|
||||||
type: 'match-updated',
|
|
||||||
payload: { matchId: updated.id, updatedAt: new Date().toISOString() },
|
|
||||||
})
|
|
||||||
|
|
||||||
// 6) Response (no-store)
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: updated.id,
|
id: updated.id,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
// /app/components/MapVoteBanner.tsx
|
// /app/components/MapVoteBanner.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
@ -35,18 +34,32 @@ function formatLead(minutes: number) {
|
|||||||
return `${m}min`
|
return `${m}min`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function MapVoteBanner({
|
||||||
export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes }: Props) {
|
match,
|
||||||
|
initialNow,
|
||||||
|
matchBaseTs,
|
||||||
|
sseOpensAtTs,
|
||||||
|
sseLeadMinutes,
|
||||||
|
}: Props) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
const [state, setState] = useState<MapVoteState | null>(null)
|
const [state, setState] = useState<MapVoteState | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [leadOverride, setLeadOverride] = useState<number | null>(null);
|
const [leadOverride, setLeadOverride] = useState<number | null>(null)
|
||||||
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
|
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
|
||||||
|
|
||||||
// deterministische Hydration + 1s-Ticker
|
// ⚠️ Hydration-sicher: auf dem Server rendern wir ein statisches Placeholder
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => { setMounted(true) }, [])
|
||||||
|
|
||||||
|
// clientseitiger Ticker
|
||||||
const [now, setNow] = useState(initialNow)
|
const [now, setNow] = useState(initialNow)
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -64,24 +77,17 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
setError(e?.message ?? 'Unbekannter Fehler')
|
setError(e?.message ?? 'Unbekannter Fehler')
|
||||||
}
|
}
|
||||||
}, [match.id])
|
}, [match.id])
|
||||||
|
|
||||||
|
// initial + bei Meta-Änderungen
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
|
||||||
|
|
||||||
const matchDateTs = useMemo(
|
const matchDateTs = useMemo(
|
||||||
() => (typeof matchBaseTs === 'number' ? matchBaseTs : null),
|
() => (typeof matchBaseTs === 'number' ? matchBaseTs : null),
|
||||||
[matchBaseTs]
|
[matchBaseTs]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
// SSE: nur map-vote-updated & Co. beachten
|
||||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// initial
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
// 🔁 Neu laden, wenn Match-Metadaten (z. B. matchDate/bestOf) sich durch refresh ändern
|
|
||||||
useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load])
|
|
||||||
|
|
||||||
// 🔁 Live-Refresh via SSE
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
const { type } = lastEvent as any
|
const { type } = lastEvent as any
|
||||||
@ -101,7 +107,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString())
|
? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString())
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// sofortige lokale Overrides, ohne auf fetch zu warten
|
// Sofort lokale Overrides setzen
|
||||||
if (nextOpensAtISO) {
|
if (nextOpensAtISO) {
|
||||||
setOpensAtOverride(new Date(nextOpensAtISO).getTime())
|
setOpensAtOverride(new Date(nextOpensAtISO).getTime())
|
||||||
} else if (Number.isFinite(parsedLead) && matchDateTs != null) {
|
} else if (Number.isFinite(parsedLead) && matchDateTs != null) {
|
||||||
@ -109,7 +115,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
}
|
}
|
||||||
if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number)
|
if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number)
|
||||||
|
|
||||||
// sichtbares Mergen (für UI-Text)
|
// sichtbares Mergen (für UI-Texte)
|
||||||
if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) {
|
if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...(prev ?? {} as any),
|
...(prev ?? {} as any),
|
||||||
@ -117,28 +123,23 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}),
|
...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}),
|
||||||
}) as any)
|
}) as any)
|
||||||
} else {
|
} else {
|
||||||
// nur nachladen, wenn Event keine konkreten Werte trug
|
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
}, [lastEvent, match.id, matchDateTs, load])
|
}, [lastEvent, match.id, matchDateTs, load])
|
||||||
|
|
||||||
// Öffnet wann?
|
// Öffnet wann? (Priorität: Parent-SSE → lokale SSE → Server → Fallback)
|
||||||
const opensAt = useMemo(() => {
|
const opensAt = useMemo(() => {
|
||||||
// höchste Priorität: vom Parent (MatchDetails) gereichter TS
|
|
||||||
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs
|
if (typeof sseOpensAtTs === 'number') return sseOpensAtTs
|
||||||
// dann lokaler SSE-Override
|
|
||||||
if (opensAtOverride != null) return opensAtOverride
|
if (opensAtOverride != null) return opensAtOverride
|
||||||
// dann Serverwert aus /mapvote
|
|
||||||
if (state?.opensAt) return new Date(state.opensAt).getTime()
|
if (state?.opensAt) return new Date(state.opensAt).getTime()
|
||||||
// Fallback aus Basis + Lead
|
|
||||||
if (matchDateTs == null) return new Date(initialNow).getTime()
|
if (matchDateTs == null) return new Date(initialNow).getTime()
|
||||||
const lead = (typeof sseLeadMinutes === 'number')
|
const lead = (typeof sseLeadMinutes === 'number')
|
||||||
? sseLeadMinutes
|
? sseLeadMinutes
|
||||||
: (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? state!.leadMinutes as number : 60))
|
: (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? (state!.leadMinutes as number) : 60))
|
||||||
return matchDateTs - lead * 60_000
|
return matchDateTs - lead * 60_000
|
||||||
}, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes])
|
}, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes])
|
||||||
|
|
||||||
// Text „startet X vor Matchbeginn“
|
// „startet X vor Matchbeginn“
|
||||||
const leadMinutes = useMemo(() => {
|
const leadMinutes = useMemo(() => {
|
||||||
if (matchDateTs != null && opensAt != null) {
|
if (matchDateTs != null && opensAt != null) {
|
||||||
return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000))
|
return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000))
|
||||||
@ -149,16 +150,16 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
return 60
|
return 60
|
||||||
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes])
|
}, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes])
|
||||||
|
|
||||||
const isOpen = now >= opensAt
|
const isOpen = mounted && now >= opensAt
|
||||||
const msToOpen = Math.max(opensAt - now, 0)
|
const msToOpen = Math.max(opensAt - now, 0)
|
||||||
|
|
||||||
const current = state?.steps?.[state.currentIndex]
|
const current = state?.steps?.[state?.currentIndex ?? 0]
|
||||||
const whoIsUp = current?.teamId
|
const whoIsUp = current?.teamId
|
||||||
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
|
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session.user.steamId
|
const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session?.user?.steamId
|
||||||
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session.user.steamId
|
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session?.user?.steamId
|
||||||
const isAdmin = !!session?.user?.isAdmin
|
const isAdmin = !!session?.user?.isAdmin
|
||||||
const iCanAct = Boolean(
|
const iCanAct = Boolean(
|
||||||
isOpen &&
|
isOpen &&
|
||||||
@ -176,7 +177,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
'dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ' +
|
'dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ' +
|
||||||
(isOpen
|
(isOpen
|
||||||
? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg'
|
? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg'
|
||||||
: 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md');
|
: 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -232,8 +233,12 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
|
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100">
|
// 🔑 Hydration-safe: vor dem Mount nur ein Placeholder rendern
|
||||||
Öffnet in {formatCountdown(msToOpen)}
|
<span
|
||||||
|
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
Öffnet in {mounted ? formatCountdown(msToOpen) : '–:–:–'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -254,7 +259,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
background-repeat: repeat-x;
|
background-repeat: repeat-x;
|
||||||
animation: slide-x 6s linear infinite; /* etwas ruhiger */
|
animation: slide-x 6s linear infinite;
|
||||||
}
|
}
|
||||||
:global(.dark) .mapVoteGradient {
|
:global(.dark) .mapVoteGradient {
|
||||||
background-image: repeating-linear-gradient(
|
background-image: repeating-linear-gradient(
|
||||||
@ -290,12 +295,10 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
transform: translateX(-120%) skewX(-20deg);
|
transform: translateX(-120%) skewX(-20deg);
|
||||||
transition: opacity .2s;
|
transition: opacity .2s;
|
||||||
}
|
}
|
||||||
/* nur wenn die Karte offen ist und gehovert wird */
|
|
||||||
:global(.group:hover) .shine::before {
|
:global(.group:hover) .shine::before {
|
||||||
animation: shine 3.8s ease-out infinite;
|
animation: shine 3.8s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Respektiere Bewegungs-Präferenzen */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.mapVoteGradient { animation: none; }
|
.mapVoteGradient { animation: none; }
|
||||||
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }
|
.shine::before { animation: none !important; transform: none !important; opacity: 0 !important; }
|
||||||
@ -303,4 +306,4 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens
|
|||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// MapVotePanel.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
|
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
|
||||||
@ -50,23 +52,62 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [adminEditMode, setAdminEditMode] = useState(false)
|
const [adminEditMode, setAdminEditMode] = useState(false)
|
||||||
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
|
const [overlayShownOnce, setOverlayShownOnce] = useState(false)
|
||||||
|
const [opensAtOverrideTs, setOpensAtOverrideTs] = useState<number | null>(null)
|
||||||
|
|
||||||
/* -------- Timers / open window -------- */
|
/* -------- Timers / open window -------- */
|
||||||
const opensAtTs = useMemo(() => {
|
// ⚠️ Wichtig: hier keine Date.now()-abhängigen Ausgaben im SSR!
|
||||||
const base = new Date(match.matchDate ?? match.demoDate ?? Date.now())
|
|
||||||
return base.getTime() - 60 * 60 * 1000
|
// stabile Basiszeit aus Props (SSR-safe; kein Date.now())
|
||||||
|
const matchBaseTs = useMemo(() => {
|
||||||
|
const raw = match.matchDate ?? match.demoDate ?? null
|
||||||
|
return raw ? new Date(raw).getTime() : null
|
||||||
}, [match.matchDate, match.demoDate])
|
}, [match.matchDate, match.demoDate])
|
||||||
|
|
||||||
const [nowTs, setNowTs] = useState(() => Date.now())
|
// vom Server geliefertes opensAt (hat Vorrang)
|
||||||
|
const opensAtTsFromServer = useMemo(() => {
|
||||||
|
return state?.opensAt ? new Date(state.opensAt).getTime() : null
|
||||||
|
}, [state?.opensAt])
|
||||||
|
|
||||||
|
const fallbackOpensAtTs = useMemo(() => {
|
||||||
|
return matchBaseTs != null ? matchBaseTs - 60 * 60 * 1000 : null
|
||||||
|
}, [matchBaseTs])
|
||||||
|
|
||||||
|
// Reihenfolge: SSE-Override → Server-Stand → Fallback
|
||||||
|
const openTs = opensAtOverrideTs ?? opensAtTsFromServer ?? fallbackOpensAtTs ?? null
|
||||||
|
|
||||||
|
// Mount-Flag, damit wir den Countdown erst clientseitig laufen lassen
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
useEffect(() => { setMounted(true) }, [])
|
||||||
|
|
||||||
|
// Countdown-State (nur nach Mount updaten, sekundensynchron)
|
||||||
|
const [msLeft, setMsLeft] = useState(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setInterval(() => setNowTs(Date.now()), 1000)
|
if (!mounted) return
|
||||||
return () => clearInterval(t)
|
if (openTs == null) { setMsLeft(0); return }
|
||||||
}, [])
|
|
||||||
|
const update = () => setMsLeft(Math.max(openTs - Date.now(), 0))
|
||||||
|
update()
|
||||||
|
|
||||||
|
// an Sekundengrenze koppeln
|
||||||
|
const drift = 1000 - (Date.now() % 1000)
|
||||||
|
let intervalId: number | null = null
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
update()
|
||||||
|
intervalId = window.setInterval(update, 1000)
|
||||||
|
}, drift)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
if (intervalId) window.clearInterval(intervalId)
|
||||||
|
}
|
||||||
|
}, [openTs, mounted])
|
||||||
|
|
||||||
|
const isOpen = mounted && (openTs != null ? msLeft <= 0 : false)
|
||||||
|
const msToOpen = msLeft
|
||||||
|
|
||||||
/* -------- Overlay integration -------- */
|
/* -------- Overlay integration -------- */
|
||||||
const overlayIsForThisMatch = overlayData?.matchId === match.id
|
const overlayIsForThisMatch = overlayData?.matchId === match.id
|
||||||
|
|
||||||
// Merken: Overlay wurde für dieses Match mindestens einmal angezeigt
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (overlayOpen && overlayIsForThisMatch) setOverlayShownOnce(true)
|
if (overlayOpen && overlayIsForThisMatch) setOverlayShownOnce(true)
|
||||||
}, [overlayOpen, overlayIsForThisMatch])
|
}, [overlayOpen, overlayIsForThisMatch])
|
||||||
@ -109,12 +150,38 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// 🔔 Reagiere auf alle relevanten Match-Events (inkl. match-updated)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
if (!MATCH_EVENTS.has(lastEvent.type)) return
|
|
||||||
if (lastEvent.payload?.matchId !== match.id) return
|
// robustes Unwrapping (doppelte payload)
|
||||||
load()
|
const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e
|
||||||
}, [lastEvent, match.id, load])
|
const evt = unwrap(lastEvent)
|
||||||
|
const type = lastEvent.type ?? evt?.type
|
||||||
|
|
||||||
|
if (evt?.matchId !== match.id) return
|
||||||
|
|
||||||
|
// ⬅️ Nur noch map-vote-updated für Counter/opensAt
|
||||||
|
if (type === 'map-vote-updated') {
|
||||||
|
const { opensAt, leadMinutes } = evt ?? {}
|
||||||
|
if (opensAt) {
|
||||||
|
const ts = new Date(opensAt).getTime()
|
||||||
|
if (Number.isFinite(ts)) setOpensAtOverrideTs(ts)
|
||||||
|
setState(prev => (prev ? { ...prev, opensAt } : prev))
|
||||||
|
} else if (Number.isFinite(leadMinutes) && matchBaseTs != null) {
|
||||||
|
const ts = matchBaseTs - Number(leadMinutes) * 60_000
|
||||||
|
setOpensAtOverrideTs(ts)
|
||||||
|
setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⬅️ Nur Map-Vote-Events triggern ein Reload (kein match-updated mehr)
|
||||||
|
const MAPVOTE_REFRESH = new Set(['map-vote-updated', 'map-vote-reset', 'map-vote-admin-edit'])
|
||||||
|
if (MAPVOTE_REFRESH.has(type)) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}, [lastEvent, match.id, load, matchBaseTs])
|
||||||
|
|
||||||
|
|
||||||
/* -------- Admin-Edit Mirror -------- */
|
/* -------- Admin-Edit Mirror -------- */
|
||||||
const adminEditingBy = state?.adminEdit?.by ?? null
|
const adminEditingBy = state?.adminEdit?.by ?? null
|
||||||
@ -125,14 +192,6 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
}, [adminEditingEnabled, adminEditingBy, session?.user?.steamId])
|
}, [adminEditingEnabled, adminEditingBy, session?.user?.steamId])
|
||||||
|
|
||||||
/* -------- Derived flags & memoized maps -------- */
|
/* -------- Derived flags & memoized maps -------- */
|
||||||
const opensAt = useMemo(
|
|
||||||
() => (state?.opensAt ? new Date(state.opensAt).getTime() : null),
|
|
||||||
[state?.opensAt]
|
|
||||||
)
|
|
||||||
const isOpenFromMatch = nowTs >= opensAtTs
|
|
||||||
const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch
|
|
||||||
const msToOpen = Math.max((opensAt ?? opensAtTs) - nowTs, 0)
|
|
||||||
|
|
||||||
const me = session?.user
|
const me = session?.user
|
||||||
const isAdmin = !!me?.isAdmin
|
const isAdmin = !!me?.isAdmin
|
||||||
const mySteamId = me?.steamId
|
const mySteamId = me?.steamId
|
||||||
@ -278,8 +337,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
}
|
}
|
||||||
if (holdMapRef.current === map) {
|
if (holdMapRef.current === map) {
|
||||||
resetHold()
|
resetHold()
|
||||||
setProgressByMap(prev => ({ ...prev, [map]: 0 }))
|
setProgressByMap(prev => ({ ...prev, [map]: 0 }))}
|
||||||
}
|
|
||||||
}, [progressByMap, resetHold, finishAndSubmit])
|
}, [progressByMap, resetHold, finishAndSubmit])
|
||||||
|
|
||||||
const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => {
|
const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => {
|
||||||
@ -495,7 +553,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : isOpen ? (
|
) : isOpen ? (
|
||||||
isFrozenByAdmin ? (
|
adminEditingEnabled && adminEditingBy !== session?.user?.steamId ? (
|
||||||
<span className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100 text-center">
|
<span className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100 text-center">
|
||||||
🔒 Admin-Edit aktiv – Voting pausiert
|
🔒 Admin-Edit aktiv – Voting pausiert
|
||||||
{(() => {
|
{(() => {
|
||||||
@ -519,8 +577,11 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 text-center">
|
<span
|
||||||
Öffnet in {fmtCountdown(msToOpen)}
|
className="block text-sm sm:text-base md:text-lg leading-tight whitespace-normal font-semibold px-2 py-1 sm:px-3 sm:py-2 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100 text-center"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
Öffnet in {mounted && openTs != null ? fmtCountdown(msToOpen) : '–:–:–'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -541,17 +602,21 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
<div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start">
|
<div className="mt-0 grid grid-cols-[0.8fr_1.4fr_0.8fr] gap-10 items-start">
|
||||||
{/* Linke Spalte */}
|
{/* Linke Spalte */}
|
||||||
<div className={`flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out ${
|
<div className={`flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out ${
|
||||||
leftIsActiveTurn ? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]' : 'bg-transparent shadow-none'
|
!!currentStep?.teamId &&
|
||||||
|
currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) &&
|
||||||
|
!state?.locked
|
||||||
|
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||||
|
: 'bg-transparent shadow-none'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<img src={getTeamLogo(teamLeft?.logo)} alt={teamLeft?.name ?? 'Team'} className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" width={12} height={12} />
|
<img src={getTeamLogo(match.teamA?.logo)} alt={match.teamA?.name ?? 'Team'} className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" width={12} height={12} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-bold text-lg truncate">{teamLeft?.name ?? 'Team'}</div>
|
<div className="font-bold text-lg truncate">{match.teamA?.name ?? 'Team'}</div>
|
||||||
</div>
|
</div>
|
||||||
<TeamPremierRankBadge players={rankLeft} />
|
<TeamPremierRankBadge players={playersA.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{playersLeft.map((p: MatchPlayer) => (
|
{playersA.map((p: MatchPlayer) => (
|
||||||
<MapVoteProfileCard
|
<MapVoteProfileCard
|
||||||
key={p.user.steamId}
|
key={p.user.steamId}
|
||||||
steamId={p.user.steamId}
|
steamId={p.user.steamId}
|
||||||
@ -561,8 +626,8 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
rank={p.stats?.rankNew ?? 0}
|
rank={p.stats?.rankNew ?? 0}
|
||||||
matchType={match.matchType}
|
matchType={match.matchType}
|
||||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||||
isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId}
|
isLeader={(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) === p.user.steamId}
|
||||||
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id) && !state.locked}
|
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) && !state.locked}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -597,22 +662,37 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
|
const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}`
|
||||||
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
|
const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled
|
||||||
|
|
||||||
|
// für DECIDER den „Chooser“ ermitteln (letztes Ban davor)
|
||||||
|
const steps = state?.steps ?? []
|
||||||
|
const decIdx = steps.findIndex(s => s.action === 'decider')
|
||||||
|
let deciderChooserTeamId: string | null = null
|
||||||
|
if (decIdx >= 0) {
|
||||||
|
for (let i = decIdx - 1; i >= 0; i--) {
|
||||||
|
const s = steps[i]
|
||||||
|
if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveTeamId =
|
const effectiveTeamId =
|
||||||
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
|
status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null
|
||||||
|
|
||||||
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === teamLeft?.id
|
const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === match.teamA?.id
|
||||||
const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === teamRight?.id
|
const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === match.teamB?.id
|
||||||
|
|
||||||
|
const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg`
|
||||||
|
|
||||||
|
// ⬇️ Fortschritt aus den TOP-LEVEL Hooks nutzen
|
||||||
const progress = progressByMap[map] ?? 0
|
const progress = progressByMap[map] ?? 0
|
||||||
const showProgress = isAvailable && progress > 0 && progress < 1
|
const showProgress = isAvailable && progress > 0 && progress < 1
|
||||||
|
|
||||||
const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg`
|
const disabledTitle = isFrozenByAdmin
|
||||||
const disabledTitle = isFrozenByAdmin ? 'Ein Admin bearbeitet gerade – Voting gesperrt' : 'Nur der Team-Leader (oder Admin) darf wählen'
|
? 'Ein Admin bearbeitet gerade – Voting gesperrt'
|
||||||
|
: 'Nur der Team-Leader (oder Admin) darf wählen'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
|
<li key={map} className="grid grid-cols-[max-content_1fr_max-content] items-center gap-2">
|
||||||
{pickedByLeft ? (
|
{pickedByLeft ? (
|
||||||
<img src={getTeamLogo(teamLeft?.logo)} alt={teamLeft?.name ?? 'Team'} className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" />
|
<img src={getTeamLogo(match.teamA?.logo)} alt={match.teamA?.name ?? 'Team'} className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" />
|
||||||
) : <div className="w-10 h-10" />}
|
) : <div className="w-10 h-10" />}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -628,9 +708,9 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
onMouseDown={() => onHoldStart(map, isAvailable)}
|
onMouseDown={() => onHoldStart(map, isAvailable)}
|
||||||
onMouseUp={() => cancelOrSubmitIfComplete(map)}
|
onMouseUp={() => cancelOrSubmitIfComplete(map)}
|
||||||
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
|
onMouseLeave={() => cancelOrSubmitIfComplete(map)}
|
||||||
onTouchStart={onTouchStart(map, isAvailable)}
|
onTouchStart={(e: React.TouchEvent) => { e.preventDefault(); onHoldStart(map, isAvailable) }}
|
||||||
onTouchEnd={onTouchEnd(map)}
|
onTouchEnd={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
|
||||||
onTouchCancel={onTouchEnd(map)}
|
onTouchCancel={(e: React.TouchEvent) => { e.preventDefault(); cancelOrSubmitIfComplete(map) }}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
|
<div className="absolute inset-0 bg-center bg-cover filter opacity-30 transition-opacity duration-300" style={{ backgroundImage: `url('${bg}')` }} />
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
@ -639,16 +719,16 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
|
|
||||||
{taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
|
{taken && (status === 'ban' || status === 'pick' || status === 'decider') && (
|
||||||
<>
|
<>
|
||||||
{(((status === 'ban' && teamId === teamLeft?.id) ||
|
{(((status === 'ban' && teamId === match.teamA?.id) ||
|
||||||
(status === 'pick' && effectiveTeamId === teamLeft?.id) ||
|
(status === 'pick' && effectiveTeamId === match.teamA?.id) ||
|
||||||
(status === 'decider' && effectiveTeamId === teamLeft?.id))) && (
|
(status === 'decider' && effectiveTeamId === match.teamA?.id))) && (
|
||||||
<span className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded transition duration-300 ease-out ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
|
<span className={`pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded transition duration-300 ease-out ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
|
||||||
{status === 'ban' ? 'Ban' : 'Pick'}
|
{status === 'ban' ? 'Ban' : 'Pick'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(((status === 'ban' && teamId === teamRight?.id) ||
|
{(((status === 'ban' && teamId === match.teamB?.id) ||
|
||||||
(status === 'pick' && effectiveTeamId === teamRight?.id) ||
|
(status === 'pick' && effectiveTeamId === match.teamB?.id) ||
|
||||||
(status === 'decider' && effectiveTeamId === teamRight?.id))) && (
|
(status === 'decider' && effectiveTeamId === match.teamB?.id))) && (
|
||||||
<span className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
|
<span className={`pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 px-2 py-0.5 text-[11px] font-semibold rounded ${status === 'ban' ? 'bg-red-600 text-white' : 'bg-green-600 text-white'}`} style={{ zIndex: 25 }}>
|
||||||
{status === 'ban' ? 'Ban' : 'Pick'}
|
{status === 'ban' ? 'Ban' : 'Pick'}
|
||||||
</span>
|
</span>
|
||||||
@ -669,7 +749,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{pickedByRight ? (
|
{pickedByRight ? (
|
||||||
<img src={getTeamLogo(teamRight?.logo)} alt={teamRight?.name ?? 'Team'} className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" />
|
<img src={getTeamLogo(match.teamB?.logo)} alt={match.teamB?.name ?? 'Team'} className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" />
|
||||||
) : <div className="w-10 h-10" />}
|
) : <div className="w-10 h-10" />}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@ -679,17 +759,21 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
|
|
||||||
{/* Rechte Spalte */}
|
{/* Rechte Spalte */}
|
||||||
<div className={`flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out ${
|
<div className={`flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out ${
|
||||||
rightIsActiveTurn ? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]' : 'bg-transparent shadow-none'
|
!!currentStep?.teamId &&
|
||||||
|
currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) &&
|
||||||
|
!state?.locked
|
||||||
|
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||||
|
: 'bg-transparent shadow-none'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<TeamPremierRankBadge players={rankRight} />
|
<TeamPremierRankBadge players={playersB.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any} />
|
||||||
<div className="min-w-0 text-right">
|
<div className="min-w-0 text-right">
|
||||||
<div className="font-bold text-lg truncate">{teamRight?.name ?? 'Team'}</div>
|
<div className="font-bold text-lg truncate">{match.teamB?.name ?? 'Team'}</div>
|
||||||
</div>
|
</div>
|
||||||
<img src={getTeamLogo(teamRight?.logo)} alt={teamRight?.name ?? 'Team'} className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" width={12} height={12} />
|
<img src={getTeamLogo(match.teamB?.logo)} alt={match.teamB?.name ?? 'Team'} className="w-12 h-12 rounded-full border bg-white dark:bg-neutral-900 object-contain" width={12} height={12} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{playersRight.map((p: MatchPlayer) => (
|
{playersB.map((p: MatchPlayer) => (
|
||||||
<MapVoteProfileCard
|
<MapVoteProfileCard
|
||||||
key={p.user.steamId}
|
key={p.user.steamId}
|
||||||
steamId={p.user.steamId}
|
steamId={p.user.steamId}
|
||||||
@ -699,8 +783,8 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
rank={p.stats?.rankNew ?? 0}
|
rank={p.stats?.rankNew ?? 0}
|
||||||
matchType={match.matchType}
|
matchType={match.matchType}
|
||||||
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
onClick={() => router.push(`/profile/${p.user.steamId}`)}
|
||||||
isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId}
|
isLeader={(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === p.user.steamId}
|
||||||
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamRightKey]?.id ?? teamRight?.id) && !state.locked}
|
isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) && !state.locked}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -717,16 +801,19 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
const chosenSteps = (state.steps ?? []).filter(
|
const chosenSteps = (state.steps ?? []).filter(
|
||||||
s => (s.action === 'pick' || s.action === 'decider') && s.map
|
s => (s.action === 'pick' || s.action === 'decider') && s.map
|
||||||
)
|
)
|
||||||
const decIdx = (state.steps ?? []).findIndex(s => s.action === 'decider')
|
// ermitteln, wer beim DECIDER "zählt"
|
||||||
|
const steps = state?.steps ?? []
|
||||||
|
const decIdx = steps.findIndex(s => s.action === 'decider')
|
||||||
let chooserTeamId: string | null = null
|
let chooserTeamId: string | null = null
|
||||||
if (decIdx >= 0) {
|
if (decIdx >= 0) {
|
||||||
for (let i = decIdx - 1; i >= 0; i--) {
|
for (let i = decIdx - 1; i >= 0; i--) {
|
||||||
const s = state.steps![i]
|
const s = steps[i]
|
||||||
if (s.action === 'ban' && s.teamId) { chooserTeamId = s.teamId; break }
|
if (s.action === 'ban' && s.teamId) { chooserTeamId = s.teamId; break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const teamLeftLogo = getTeamLogo(teamLeft?.logo)
|
|
||||||
const teamRightLogo = getTeamLogo(teamRight?.logo)
|
const teamLeftLogo = getTeamLogo(match.teamA?.logo)
|
||||||
|
const teamRightLogo = getTeamLogo(match.teamB?.logo)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
@ -740,8 +827,8 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
const pickTeamId = action === 'pick' ? (step?.teamId ?? null)
|
const pickTeamId = action === 'pick' ? (step?.teamId ?? null)
|
||||||
: action === 'decider' ? chooserTeamId
|
: action === 'decider' ? chooserTeamId
|
||||||
: null
|
: null
|
||||||
const pickedByLeft = pickTeamId && pickTeamId === teamLeft?.id
|
const pickedByLeft = pickTeamId && pickTeamId === match.teamA?.id
|
||||||
const pickedByRight = pickTeamId && pickTeamId === teamRight?.id
|
const pickedByRight = pickTeamId && pickTeamId === match.teamB?.id
|
||||||
const sideLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null
|
const sideLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null
|
||||||
const frameClasses =
|
const frameClasses =
|
||||||
action === 'pick' ? 'ring-2 ring-green-500'
|
action === 'pick' ? 'ring-2 ring-green-500'
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
// /app/components/MatchDetails.tsx
|
// /app/components/MatchDetails.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
@ -23,7 +22,6 @@ import { useSSEStore } from '@/app/lib/useSSEStore'
|
|||||||
import { Team } from '../types/team'
|
import { Team } from '../types/team'
|
||||||
import Alert from './Alert'
|
import Alert from './Alert'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { MATCH_EVENTS } from '../lib/sseEvents'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
|
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
|
||||||
@ -50,8 +48,7 @@ type VoteStep = { order: number; action: VoteAction; map?: string | null }
|
|||||||
const mapLabelFromKey = (key?: string) => {
|
const mapLabelFromKey = (key?: string) => {
|
||||||
const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'')
|
const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'')
|
||||||
return (
|
return (
|
||||||
MAP_OPTIONS.find(o => o.key === k)?.label ??
|
MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD')
|
||||||
(k ? k : 'TBD')
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,7 +59,6 @@ function extractSeriesMaps(match: Match): string[] {
|
|||||||
.filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER'))
|
.filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER'))
|
||||||
.sort((a,b) => (a.order ?? 0) - (b.order ?? 0))
|
.sort((a,b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
.map(s => s.map ?? '')
|
.map(s => s.map ?? '')
|
||||||
// auf bestOf begrenzen
|
|
||||||
const n = Math.max(1, match.bestOf ?? 1)
|
const n = Math.max(1, match.bestOf ?? 1)
|
||||||
return picks.slice(0, n)
|
return picks.slice(0, n)
|
||||||
}
|
}
|
||||||
@ -83,13 +79,11 @@ function SeriesStrip({
|
|||||||
const needed = Math.ceil(bestOf / 2)
|
const needed = Math.ceil(bestOf / 2)
|
||||||
const total = Math.max(bestOf, maps.length || 1)
|
const total = Math.max(bestOf, maps.length || 1)
|
||||||
|
|
||||||
// index der "aktuellen" Map: sobald jemand fertig ist → keine aktuelle Markierung
|
|
||||||
const finished = winsA >= needed || winsB >= needed
|
const finished = winsA >= needed || winsB >= needed
|
||||||
const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1)
|
const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* Kopfzeile der Serie */}
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-gray-400">
|
||||||
Best of {bestOf} • First to {needed}
|
Best of {bestOf} • First to {needed}
|
||||||
@ -99,14 +93,11 @@ function SeriesStrip({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kartenleiste */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
{Array.from({ length: total }).map((_, i) => {
|
{Array.from({ length: total }).map((_, i) => {
|
||||||
const key = maps[i] ?? ''
|
const key = maps[i] ?? ''
|
||||||
const label = mapLabelFromKey(key)
|
const label = mapLabelFromKey(key)
|
||||||
|
|
||||||
// Siegerbadge pro Map (heuristisch): mapsiegereihenfolge = Sum wins so far?
|
|
||||||
// Da wir kein per-Map Ergebnis haben, markieren wir nur globalen Fortschritt:
|
|
||||||
const isDone = i < winsA + winsB
|
const isDone = i < winsA + winsB
|
||||||
const isCurrent = i === currentIdx
|
const isCurrent = i === currentIdx
|
||||||
const isFuture = i > winsA + winsB
|
const isFuture = i > winsA + winsB
|
||||||
@ -144,18 +135,22 @@ function SeriesStrip({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ─────────────────── Komponente ─────────────────────────────── */
|
/* ─────────────────── Komponente ─────────────────────────────── */
|
||||||
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
|
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isAdmin = !!session?.user?.isAdmin
|
const isAdmin = !!session?.user?.isAdmin
|
||||||
|
|
||||||
|
// Hydration-sicher: keine sich ändernden Werte im SSR rendern
|
||||||
|
// Wir brauchen "now" nur, um zu entscheiden, ob Mapvote schon gestartet ist
|
||||||
const [now, setNow] = useState(initialNow)
|
const [now, setNow] = useState(initialNow)
|
||||||
const [editMetaOpen, setEditMetaOpen] = useState(false)
|
const [editMetaOpen, setEditMetaOpen] = useState(false)
|
||||||
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null);
|
|
||||||
const [leadOverride, setLeadOverride] = useState<number | null>(null);
|
|
||||||
|
|
||||||
|
// Lokale Overrides (analog MapVoteBanner), damit die Clients sofort reagieren
|
||||||
|
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
|
||||||
|
const [leadOverride, setLeadOverride] = useState<number | null>(null)
|
||||||
|
const lastHandledKeyRef = useRef<string>('')
|
||||||
|
|
||||||
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
/* ─── Rollen & Rechte ─────────────────────────────────────── */
|
||||||
const me = session?.user
|
const me = session?.user
|
||||||
@ -168,14 +163,14 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
|
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
|
||||||
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
|
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
|
||||||
|
|
||||||
/* ─── Map ─────────────────────────────────────────────────── */
|
/* ─── Map-Label ───────────────────────────────────────────── */
|
||||||
const mapKey = normalizeMapKey(match.map)
|
const mapKey = normalizeMapKey(match.map)
|
||||||
const mapLabel =
|
const mapLabel =
|
||||||
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
|
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
|
||||||
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ??
|
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ??
|
||||||
'Unbekannte Map'
|
'Unbekannte Map'
|
||||||
|
|
||||||
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
|
/* ─── Match-Zeitpunkt (vom Server; ändert sich via router.refresh) ─── */
|
||||||
const dateString = match.matchDate ?? match.demoDate
|
const dateString = match.matchDate ?? match.demoDate
|
||||||
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
|
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
|
||||||
|
|
||||||
@ -185,32 +180,33 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')]
|
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')]
|
||||||
}, [match.bestOf, match.mapVote?.steps?.length])
|
}, [match.bestOf, match.mapVote?.steps?.length])
|
||||||
|
|
||||||
|
/* ─── Modal-State ─────────────────────────────────────────── */
|
||||||
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
|
|
||||||
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
||||||
|
|
||||||
/* ─── Live-Uhr (für vote-Zeitpunkt) ───────────────────────── */
|
/* ─── Live-Uhr für Mapvote-Startfenster ───────────────────── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Basiszeit des Matches (stabil; für Berechnung von opensAt-Fallback)
|
||||||
// Basiszeit des Matches einmal berechnen
|
|
||||||
const matchBaseTs = useMemo(() => {
|
const matchBaseTs = useMemo(() => {
|
||||||
const raw = match.matchDate ?? match.demoDate ?? initialNow;
|
const raw = match.matchDate ?? match.demoDate ?? initialNow
|
||||||
return new Date(raw).getTime();
|
return new Date(raw).getTime()
|
||||||
}, [match.matchDate, match.demoDate, initialNow]);
|
}, [match.matchDate, match.demoDate, initialNow])
|
||||||
|
|
||||||
|
// Zeitpunkt, wann der Mapvote öffnet (Parent errechnet und an Banner gereicht)
|
||||||
const voteOpensAtTs = useMemo(() => {
|
const voteOpensAtTs = useMemo(() => {
|
||||||
if (opensAtOverride != null) return opensAtOverride; // SSE hat Vorrang
|
if (opensAtOverride != null) return opensAtOverride
|
||||||
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime(); // vom Server
|
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime()
|
||||||
const lead = (leadOverride != null) ? leadOverride : 60; // kein 60-min Zwang
|
const lead = (leadOverride != null)
|
||||||
return matchBaseTs - lead * 60_000;
|
? leadOverride
|
||||||
}, [opensAtOverride, match.mapVote?.opensAt, matchBaseTs, leadOverride]);
|
: (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
|
||||||
|
return matchBaseTs - lead * 60_000
|
||||||
const sseOpensAtTs = voteOpensAtTs;
|
}, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride])
|
||||||
const sseLeadMinutes = leadOverride;
|
|
||||||
|
const sseOpensAtTs = voteOpensAtTs
|
||||||
|
const sseLeadMinutes = leadOverride
|
||||||
|
|
||||||
const endDate = new Date(voteOpensAtTs)
|
const endDate = new Date(voteOpensAtTs)
|
||||||
const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs
|
const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs
|
||||||
@ -218,39 +214,53 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
const showEditA = canEditA && !mapvoteStarted
|
const showEditA = canEditA && !mapvoteStarted
|
||||||
const showEditB = canEditB && !mapvoteStarted
|
const showEditB = canEditB && !mapvoteStarted
|
||||||
|
|
||||||
/* ─── SSE-Listener ─────────────────────────────────────────── */
|
/* ─── SSE-Listener (nur map-vote-updated & Co.) ───────────── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return;
|
if (!lastEvent) return
|
||||||
const evt = (lastEvent as any).payload ?? lastEvent;
|
|
||||||
if (evt?.matchId !== match.id) return;
|
|
||||||
|
|
||||||
if (lastEvent.type === 'map-vote-updated') {
|
// robustes Unwrap
|
||||||
// opensAt aus Event übernehmen
|
const outer = lastEvent as any
|
||||||
|
const maybeInner = outer?.payload
|
||||||
|
const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner)
|
||||||
|
? maybeInner
|
||||||
|
: outer
|
||||||
|
|
||||||
|
const type = base?.type
|
||||||
|
const evt = base?.payload ?? base
|
||||||
|
if (!evt?.matchId || evt.matchId !== match.id) return
|
||||||
|
|
||||||
|
// Dedupe-Key
|
||||||
|
const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}`
|
||||||
|
if (key === lastHandledKeyRef.current) {
|
||||||
|
// identisches Event bereits verarbeitet → ignorieren
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastHandledKeyRef.current = key
|
||||||
|
|
||||||
|
// eigentliche Verarbeitung
|
||||||
|
if (type === 'map-vote-updated') {
|
||||||
if (evt?.opensAt) {
|
if (evt?.opensAt) {
|
||||||
const ts = typeof evt.opensAt === 'string'
|
const ts = new Date(evt.opensAt).getTime()
|
||||||
? new Date(evt.opensAt).getTime()
|
setOpensAtOverride(ts)
|
||||||
: new Date(evt.opensAt).getTime();
|
|
||||||
setOpensAtOverride(ts);
|
|
||||||
}
|
}
|
||||||
// leadMinutes mitschneiden u. ggf. opensAt daraus ableiten
|
|
||||||
if (Number.isFinite(evt?.leadMinutes)) {
|
if (Number.isFinite(evt?.leadMinutes)) {
|
||||||
const lead = Number(evt.leadMinutes);
|
const lead = Number(evt.leadMinutes)
|
||||||
setLeadOverride(lead);
|
setLeadOverride(lead)
|
||||||
if (!evt?.opensAt) {
|
if (!evt?.opensAt) {
|
||||||
const base = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime();
|
const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
|
||||||
setOpensAtOverride(base - lead * 60_000);
|
setOpensAtOverride(baseTs - lead * 60_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// damit match.matchDate & Co. neu vom Server kommen
|
||||||
|
router.refresh()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const REFRESH_TYPES = new Set([
|
const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
|
||||||
'map-vote-reset','map-vote-locked','map-vote-unlocked',
|
if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) {
|
||||||
'match-updated','match-lineup-updated',
|
router.refresh()
|
||||||
]);
|
|
||||||
if (REFRESH_TYPES.has(lastEvent.type) && evt?.matchId === match.id) {
|
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow]);
|
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
|
||||||
|
|
||||||
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
|
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
|
||||||
const ColGroup = () => (
|
const ColGroup = () => (
|
||||||
@ -292,21 +302,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
<Table.Head>
|
<Table.Head>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
{[
|
{[
|
||||||
'Spieler',
|
'Spieler', 'Rank', 'Aim', 'K', 'A', 'D',
|
||||||
'Rank',
|
'1K', '2K', '3K', '4K', '5K',
|
||||||
'Aim',
|
'K/D', 'ADR', 'HS%', 'Damage',
|
||||||
'K',
|
|
||||||
'A',
|
|
||||||
'D',
|
|
||||||
'1K',
|
|
||||||
'2K',
|
|
||||||
'3K',
|
|
||||||
'4K',
|
|
||||||
'5K',
|
|
||||||
'K/D',
|
|
||||||
'ADR',
|
|
||||||
'HS%',
|
|
||||||
'Damage',
|
|
||||||
].map((h) => (
|
].map((h) => (
|
||||||
<Table.Cell key={h} as="th">
|
<Table.Cell key={h} as="th">
|
||||||
{h}
|
{h}
|
||||||
@ -333,11 +331,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex items-center gap-[6px]">
|
||||||
{match.matchType === 'premier' ? (
|
{match.matchType === 'premier'
|
||||||
<PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
|
||||||
) : (
|
: <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
|
||||||
<CompRankBadge rank={p.stats?.rankNew ?? 0} />
|
|
||||||
)}
|
|
||||||
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
|
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${
|
className={`text-sm ${
|
||||||
@ -384,14 +380,12 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Kopfzeile: Zurück + Admin-Buttons */}
|
{/* Kopfzeile: Zurück + Admin-Buttons */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Links: Zurück */}
|
|
||||||
<Link href="/schedule">
|
<Link href="/schedule">
|
||||||
<Button color="gray" variant="outline">
|
<Button color="gray" variant="outline">
|
||||||
← Zurück
|
← Zurück
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Rechts: Admin-Buttons */}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@ -414,6 +408,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
Match auf {mapLabel} ({match.matchType})
|
Match auf {mapLabel} ({match.matchType})
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
{/* Hydration-sicher: Datum kommt vom Server und ändert sich nach SSE via router.refresh() */}
|
||||||
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
||||||
|
|
||||||
{(match.bestOf ?? 1) > 1 && (
|
{(match.bestOf ?? 1) > 1 && (
|
||||||
@ -422,7 +417,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
bestOf={match.bestOf ?? 3}
|
bestOf={match.bestOf ?? 3}
|
||||||
scoreA={match.scoreA}
|
scoreA={match.scoreA}
|
||||||
scoreB={match.scoreB}
|
scoreB={match.scoreB}
|
||||||
maps={seriesMaps}
|
maps={extractSeriesMaps(match)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -435,6 +430,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* MapVote-Banner erhält die aktuell berechneten (SSE-konformen) Werte */}
|
||||||
<MapVoteBanner
|
<MapVoteBanner
|
||||||
match={match}
|
match={match}
|
||||||
initialNow={initialNow}
|
initialNow={initialNow}
|
||||||
@ -452,25 +448,18 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
|
|
||||||
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{showEditA ? (
|
{canEditA && !mapvoteStarted ? (
|
||||||
<>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
||||||
{/* Unlocked-Icon */}
|
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
</svg>
|
||||||
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
|
) : (
|
||||||
</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
|
||||||
</>
|
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
|
||||||
) : (
|
</svg>
|
||||||
<>
|
)}
|
||||||
{/* Locked-Icon */}
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
|
|
||||||
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
|
|
||||||
</svg>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<span className='text-gray-300'>
|
<span className='text-gray-300'>
|
||||||
{showEditA ? (
|
{canEditA && !mapvoteStarted ? (
|
||||||
<>
|
<>
|
||||||
Du kannst die Aufstellung noch bis{' '}
|
Du kannst die Aufstellung noch bis{' '}
|
||||||
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||||
@ -483,10 +472,10 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => showEditA && setEditSide('A')}
|
onClick={() => (canEditA && !mapvoteStarted) && setEditSide('A')}
|
||||||
disabled={!showEditA}
|
disabled={!(canEditA && !mapvoteStarted)}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg ${
|
className={`px-3 py-1.5 text-sm rounded-lg ${
|
||||||
showEditA
|
canEditA && !mapvoteStarted
|
||||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
@ -506,11 +495,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
{match.teamB?.logo && (
|
{match.teamB?.logo && (
|
||||||
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
|
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
|
||||||
<Image
|
<Image
|
||||||
src={
|
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
|
||||||
match.teamB.logo
|
|
||||||
? `/assets/img/logos/${match.teamB.logo}`
|
|
||||||
: `/assets/img/logos/cs2.webp`
|
|
||||||
}
|
|
||||||
alt="Teamlogo"
|
alt="Teamlogo"
|
||||||
fill
|
fill
|
||||||
sizes="64px"
|
sizes="64px"
|
||||||
@ -522,28 +507,20 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
{match.teamB?.name ?? 'Team B'}
|
{match.teamB?.name ?? 'Team B'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{showEditB ? (
|
{canEditB && !mapvoteStarted ? (
|
||||||
<>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
||||||
{/* Unlocked-Icon */}
|
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
|
</svg>
|
||||||
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
|
) : (
|
||||||
</svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
|
||||||
</>
|
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
|
||||||
) : (
|
</svg>
|
||||||
<>
|
)}
|
||||||
{/* Locked-Icon */}
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
|
|
||||||
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
|
|
||||||
</svg>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<span className='text-gray-300'>
|
<span className='text-gray-300'>
|
||||||
{showEditB ? (
|
{canEditB && !mapvoteStarted ? (
|
||||||
<>
|
<>
|
||||||
Du kannst die Aufstellung noch bis{' '}
|
Du kannst die Aufstellung noch bis{' '}
|
||||||
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
||||||
@ -556,10 +533,10 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => showEditB && setEditSide('B')}
|
onClick={() => (canEditB && !mapvoteStarted) && setEditSide('B')}
|
||||||
disabled={!showEditB}
|
disabled={!(canEditB && !mapvoteStarted)}
|
||||||
className={`px-3 py-1.5 text-sm rounded-lg ${
|
className={`px-3 py-1.5 text-sm rounded-lg ${
|
||||||
showEditB
|
canEditB && !mapvoteStarted
|
||||||
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||||
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
@ -584,7 +561,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
side={editSide}
|
side={editSide}
|
||||||
initialA={teamAPlayers.map((mp) => mp.user.steamId)}
|
initialA={teamAPlayers.map((mp) => mp.user.steamId)}
|
||||||
initialB={teamBPlayers.map((mp) => mp.user.steamId)}
|
initialB={teamBPlayers.map((mp) => mp.user.steamId)}
|
||||||
onSaved={() => router.refresh()} // sanfter als window.location.reload()
|
onSaved={() => router.refresh()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -600,7 +577,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
defaultTeamBName={match.teamB?.name ?? null}
|
defaultTeamBName={match.teamB?.name ?? null}
|
||||||
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
||||||
defaultMap={match.map ?? null}
|
defaultMap={match.map ?? null}
|
||||||
defaultVoteLeadMinutes={60}
|
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
|
||||||
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,23 +1,24 @@
|
|||||||
// /app/components/LiveRadar.tsx
|
// /app/components/radar/LiveRadar.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import MetaSocket from './MetaSocket'
|
||||||
|
import PositionsSocket from './PositionsSocket'
|
||||||
|
|
||||||
/* ───────────────── UI ───────────────── */
|
/* ───────── UI config ───────── */
|
||||||
const UI = {
|
const UI = {
|
||||||
player: {
|
player: {
|
||||||
minRadiusPx: 4,
|
minRadiusPx: 4,
|
||||||
radiusRel: 0.008, // relativ zur kleineren Bildkante
|
radiusRel: 0.008,
|
||||||
dirLenRel: 0.70, // Anteil des Radius
|
dirLenRel: 0.70,
|
||||||
dirMinLenPx: 6,
|
dirMinLenPx: 6,
|
||||||
lineWidthRel: 0.25,
|
lineWidthRel: 0.25,
|
||||||
stroke: '#ffffff',
|
stroke: '#ffffff',
|
||||||
fillCT: '#3b82f6',
|
fillCT: '#3b82f6',
|
||||||
fillT: '#f59e0b',
|
fillT: '#f59e0b',
|
||||||
dirColor: 'auto' as 'auto' | string, // 'auto' = Kontrast zum Kreis
|
dirColor: 'auto' as 'auto' | string,
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ───────────────── UI (Grenades) ───────────────── */
|
|
||||||
nade: {
|
nade: {
|
||||||
stroke: '#111111',
|
stroke: '#111111',
|
||||||
smokeFill: 'rgba(160,160,160,0.35)',
|
smokeFill: 'rgba(160,160,160,0.35)',
|
||||||
@ -27,10 +28,11 @@ const UI = {
|
|||||||
decoyFill: 'rgba(140,140,255,0.25)',
|
decoyFill: 'rgba(140,140,255,0.25)',
|
||||||
teamStrokeCT: '#3b82f6',
|
teamStrokeCT: '#3b82f6',
|
||||||
teamStrokeT: '#f59e0b',
|
teamStrokeT: '#f59e0b',
|
||||||
minRadiusPx: 6
|
minRadiusPx: 6,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────── helpers ───────── */
|
||||||
function contrastStroke(hex: string) {
|
function contrastStroke(hex: string) {
|
||||||
const h = hex.replace('#','')
|
const h = hex.replace('#','')
|
||||||
const r = parseInt(h.slice(0,2),16)/255
|
const r = parseInt(h.slice(0,2),16)/255
|
||||||
@ -47,25 +49,31 @@ function mapTeam(t: any): 'T' | 'CT' | string {
|
|||||||
return String(t ?? '')
|
return String(t ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const RAD2DEG = 180 / Math.PI;
|
function buildWsUrl(prefix: 'CS2_META' | 'CS2_POS') {
|
||||||
|
const host = process.env[`NEXT_PUBLIC_${prefix}_WS_HOST`] || '127.0.0.1'
|
||||||
|
const port = String(process.env[`NEXT_PUBLIC_${prefix}_WS_PORT`] || (prefix === 'CS2_META' ? '443' : '8082'))
|
||||||
|
const path = process.env[`NEXT_PUBLIC_${prefix}_WS_PATH`] || '/telemetry'
|
||||||
|
|
||||||
function normalizeDeg(d: number) {
|
// Heuristik: wenn explizit 443 -> wss, wenn Seite https und Host != localhost -> wss, sonst ws
|
||||||
d = d % 360;
|
const isLocal = ['127.0.0.1', 'localhost', '::1'].includes(host)
|
||||||
return d < 0 ? d + 360 : d;
|
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
|
||||||
|
const proto = (port === '443' || (!isLocal && pageHttps)) ? 'wss' : 'ws'
|
||||||
|
|
||||||
|
const portPart = (port === '80' || port === '443') ? '' : `:${port}`
|
||||||
|
return `${proto}://${host}${portPart}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVec3String(str?: string) {
|
|
||||||
if (!str || typeof str !== 'string') return { x: 0, y: 0, z: 0 };
|
|
||||||
const [x, y, z] = str.split(',').map(s => Number(s.trim()));
|
|
||||||
return {
|
|
||||||
x: Number.isFinite(x) ? x : 0,
|
|
||||||
y: Number.isFinite(y) ? y : 0,
|
|
||||||
z: Number.isFinite(z) ? z : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function asNum(n: any, def=0) { const v = Number(n); return Number.isFinite(v) ? v : def }
|
|
||||||
|
|
||||||
/* ───────────────── Types ───────────────── */
|
const RAD2DEG = 180 / Math.PI
|
||||||
|
const normalizeDeg = (d: number) => (d % 360 + 360) % 360
|
||||||
|
const parseVec3String = (str?: string) => {
|
||||||
|
if (!str || typeof str !== 'string') return { x: 0, y: 0, z: 0 }
|
||||||
|
const [x, y, z] = str.split(',').map(s => Number(s.trim()))
|
||||||
|
return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0, z: Number.isFinite(z) ? z : 0 }
|
||||||
|
}
|
||||||
|
const asNum = (n: any, def=0) => { const v = Number(n); return Number.isFinite(v) ? v : def }
|
||||||
|
|
||||||
|
/* ───────── types ───────── */
|
||||||
type PlayerState = {
|
type PlayerState = {
|
||||||
id: string
|
id: string
|
||||||
name?: string | null
|
name?: string | null
|
||||||
@ -73,10 +81,9 @@ type PlayerState = {
|
|||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
z: number
|
z: number
|
||||||
yaw?: number | null // Grad
|
yaw?: number | null
|
||||||
alive?: boolean
|
alive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Grenade = {
|
type Grenade = {
|
||||||
id: string
|
id: string
|
||||||
kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown'
|
kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown'
|
||||||
@ -87,24 +94,22 @@ type Grenade = {
|
|||||||
expiresAt?: number | null
|
expiresAt?: number | null
|
||||||
team?: 'T' | 'CT' | string | null
|
team?: 'T' | 'CT' | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
|
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
|
||||||
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
||||||
|
|
||||||
/* ───────────────── Komponente ───────────────── */
|
/* ───────── Komponente ───────── */
|
||||||
export default function LiveRadar() {
|
export default function LiveRadar() {
|
||||||
const [wsStatus, setWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
// WS-Status separat anzeigen
|
||||||
|
const [metaWsStatus, setMetaWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
||||||
|
const [posWsStatus, setPosWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
||||||
|
|
||||||
|
// Zustand
|
||||||
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
|
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
|
||||||
|
|
||||||
// Spieler (throttled)
|
|
||||||
const playersRef = useRef<Map<string, PlayerState>>(new Map())
|
const playersRef = useRef<Map<string, PlayerState>>(new Map())
|
||||||
const [players, setPlayers] = useState<PlayerState[]>([])
|
const [players, setPlayers] = useState<PlayerState[]>([])
|
||||||
|
|
||||||
// Grenades (throttled)
|
|
||||||
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
|
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
|
||||||
const [grenades, setGrenades] = useState<Grenade[]>([])
|
const [grenades, setGrenades] = useState<Grenade[]>([])
|
||||||
|
|
||||||
// gemeinsamer Flush (Players + Grenades)
|
|
||||||
const flushTimer = useRef<number | null>(null)
|
const flushTimer = useRef<number | null>(null)
|
||||||
const scheduleFlush = () => {
|
const scheduleFlush = () => {
|
||||||
if (flushTimer.current != null) return
|
if (flushTimer.current != null) return
|
||||||
@ -114,233 +119,193 @@ export default function LiveRadar() {
|
|||||||
setGrenades(Array.from(grenadesRef.current.values()))
|
setGrenades(Array.from(grenadesRef.current.values()))
|
||||||
}, 66)
|
}, 66)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────────── WebSocket ───────────── */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
return () => {
|
||||||
|
if (flushTimer.current != null) {
|
||||||
const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL
|
window.clearTimeout(flushTimer.current)
|
||||||
const host = process.env.NEXT_PUBLIC_CS2_WS_HOST || window.location.hostname
|
flushTimer.current = null
|
||||||
const port = process.env.NEXT_PUBLIC_CS2_WS_PORT || ''
|
|
||||||
const path = process.env.NEXT_PUBLIC_CS2_WS_PATH || '/telemetry'
|
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
|
||||||
const portPart = port && port !== '80' && port !== '443' ? `:${port}` : ''
|
|
||||||
const url = explicit || `${proto}://${host}${portPart}${path}`
|
|
||||||
|
|
||||||
let alive = true
|
|
||||||
let ws: WebSocket | null = null
|
|
||||||
let retry: number | null = null
|
|
||||||
|
|
||||||
const upsertPlayer = (e: any) => {
|
|
||||||
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
|
|
||||||
if (!id) return
|
|
||||||
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates
|
|
||||||
const x = Number(e.x ?? pos?.x ?? (Array.isArray(pos) ? pos?.[0] : undefined))
|
|
||||||
const y = Number(e.y ?? pos?.y ?? (Array.isArray(pos) ? pos?.[1] : undefined))
|
|
||||||
const z = Number(e.z ?? pos?.z ?? (Array.isArray(pos) ? pos?.[2] : 0))
|
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
||||||
|
|
||||||
const yaw = Number(
|
|
||||||
e.yaw ??
|
|
||||||
e.viewAngle?.yaw ??
|
|
||||||
e.view?.yaw ??
|
|
||||||
e.aim?.yaw ??
|
|
||||||
e.ang?.y ??
|
|
||||||
e.angles?.y ??
|
|
||||||
e.rotation?.yaw
|
|
||||||
)
|
|
||||||
|
|
||||||
playersRef.current.set(id, {
|
|
||||||
id,
|
|
||||||
name: e.name ?? null,
|
|
||||||
team: mapTeam(e.team),
|
|
||||||
x, y, z,
|
|
||||||
yaw: Number.isFinite(yaw) ? yaw : null,
|
|
||||||
alive: e.alive,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// >>> GSI-Zuschauer-Format verarbeiten
|
|
||||||
const handleAllPlayers = (msg: any) => {
|
|
||||||
const ap = msg?.allplayers
|
|
||||||
if (!ap || typeof ap !== 'object') return
|
|
||||||
for (const key of Object.keys(ap)) {
|
|
||||||
const p = ap[key]
|
|
||||||
const pos = parseVec3String(p.position) // "x, y, z" -> {x,y,z}
|
|
||||||
const fwd = parseVec3String(p.forward)
|
|
||||||
// yaw aus forward (x,y)
|
|
||||||
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
|
|
||||||
|
|
||||||
const id = String(key) // in GSI-Snapshots ist das meist die Entität/Steam-ähnliche ID
|
|
||||||
playersRef.current.set(id, {
|
|
||||||
id,
|
|
||||||
name: p.name ?? null,
|
|
||||||
team: mapTeam(p.team),
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y,
|
|
||||||
z: pos.z,
|
|
||||||
yaw,
|
|
||||||
alive: p.state?.health > 0 || p.state?.health == null ? true : false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Grenades normalisieren (tolerant gegen versch. Formate)
|
const metaUrl = buildWsUrl('CS2_META')
|
||||||
|
const posUrl = buildWsUrl('CS2_POS')
|
||||||
|
|
||||||
|
/* ───────── Meta-Callbacks ───────── */
|
||||||
|
const handleMetaMap = (key: string) => setActiveMapKey(key.toLowerCase())
|
||||||
|
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
|
||||||
|
for (const p of list) {
|
||||||
|
const id = String(p.steamId ?? '')
|
||||||
|
if (!id) continue
|
||||||
|
const old = playersRef.current.get(id)
|
||||||
|
playersRef.current.set(id, {
|
||||||
|
id,
|
||||||
|
name: p.name ?? old?.name ?? null,
|
||||||
|
team: mapTeam(p.team ?? old?.team),
|
||||||
|
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
|
||||||
|
yaw: old?.yaw ?? null,
|
||||||
|
alive: old?.alive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
const handleMetaPlayerJoin = (p: any) => {
|
||||||
|
const id = String(p?.steamId ?? p?.id ?? p?.name ?? '')
|
||||||
|
if (!id) return
|
||||||
|
const old = playersRef.current.get(id)
|
||||||
|
playersRef.current.set(id, {
|
||||||
|
id,
|
||||||
|
name: p?.name ?? old?.name ?? null,
|
||||||
|
team: mapTeam(p?.team ?? old?.team),
|
||||||
|
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
|
||||||
|
yaw: old?.yaw ?? null,
|
||||||
|
alive: true,
|
||||||
|
})
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
const handleMetaPlayerLeave = (steamId: string | number) => {
|
||||||
|
const id = String(steamId)
|
||||||
|
const old = playersRef.current.get(id)
|
||||||
|
if (old) {
|
||||||
|
playersRef.current.set(id, { ...old, alive: false })
|
||||||
|
scheduleFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────── Positions-Callbacks ───────── */
|
||||||
|
const upsertPlayer = (e: any) => {
|
||||||
|
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
|
||||||
|
if (!id) return
|
||||||
|
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates
|
||||||
|
const x = Number(e.x ?? pos?.x ?? (Array.isArray(pos) ? pos?.[0] : undefined))
|
||||||
|
const y = Number(e.y ?? pos?.y ?? (Array.isArray(pos) ? pos?.[1] : undefined))
|
||||||
|
const z = Number(e.z ?? pos?.z ?? (Array.isArray(pos) ? pos?.[2] : 0))
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
||||||
|
|
||||||
|
const yaw = Number(
|
||||||
|
e.yaw ??
|
||||||
|
e.viewAngle?.yaw ??
|
||||||
|
e.view?.yaw ??
|
||||||
|
e.aim?.yaw ??
|
||||||
|
e.ang?.y ??
|
||||||
|
e.angles?.y ??
|
||||||
|
e.rotation?.yaw
|
||||||
|
)
|
||||||
|
|
||||||
|
const old = playersRef.current.get(id)
|
||||||
|
playersRef.current.set(id, {
|
||||||
|
id,
|
||||||
|
name: e.name ?? old?.name ?? null,
|
||||||
|
team: mapTeam(e.team ?? old?.team),
|
||||||
|
x, y, z,
|
||||||
|
yaw: Number.isFinite(yaw) ? yaw : old?.yaw ?? null,
|
||||||
|
alive: e.alive ?? old?.alive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const handlePlayersAll = (msg: any) => {
|
||||||
|
const ap = msg?.allplayers
|
||||||
|
if (!ap || typeof ap !== 'object') return
|
||||||
|
for (const key of Object.keys(ap)) {
|
||||||
|
const p = ap[key]
|
||||||
|
const pos = parseVec3String(p.position)
|
||||||
|
const fwd = parseVec3String(p.forward)
|
||||||
|
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
|
||||||
|
const id = String(key)
|
||||||
|
const old = playersRef.current.get(id)
|
||||||
|
playersRef.current.set(id, {
|
||||||
|
id,
|
||||||
|
name: p.name ?? old?.name ?? null,
|
||||||
|
team: mapTeam(p.team ?? old?.team),
|
||||||
|
x: pos.x, y: pos.y, z: pos.z,
|
||||||
|
yaw,
|
||||||
|
alive: p.state?.health > 0 || p.state?.health == null ? true : false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalizeGrenades = (raw: any): Grenade[] => {
|
||||||
|
if (!raw) return []
|
||||||
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
|
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
|
||||||
const s = mapTeam(t)
|
const s = mapTeam(t)
|
||||||
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null)
|
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null)
|
||||||
}
|
}
|
||||||
const normalizeGrenades = (raw: any): Grenade[] => {
|
if (Array.isArray(raw)) {
|
||||||
if (!raw) return []
|
return raw.map((g: any, i: number) => {
|
||||||
|
const pos = g.pos ?? g.position ?? g.location ?? {}
|
||||||
// 1) Falls schon Array [{type, pos{x,y,z}, ...}]
|
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
|
||||||
if (Array.isArray(raw)) {
|
typeof pos === 'string' ? parseVec3String(pos) : pos
|
||||||
return raw.map((g: any, i: number) => {
|
return {
|
||||||
const pos = g.pos ?? g.position ?? g.location ?? {}
|
id: String(g.id ?? `${g.type ?? 'nade'}#${i}`),
|
||||||
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
|
kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']),
|
||||||
typeof pos === 'string' ? parseVec3String(pos) : pos
|
x: asNum(g.x ?? xyz?.x), y: asNum(g.y ?? xyz?.y), z: asNum(g.z ?? xyz?.z),
|
||||||
return {
|
radius: Number.isFinite(Number(g.radius)) ? Number(g.radius) : null,
|
||||||
id: String(g.id ?? `${g.type ?? 'nade'}#${i}`),
|
expiresAt: Number.isFinite(Number(g.expiresAt)) ? Number(g.expiresAt) : null,
|
||||||
kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']),
|
team: pickTeam(g.team ?? g.owner_team ?? g.side ?? null),
|
||||||
x: asNum(g.x ?? xyz?.x), y: asNum(g.y ?? xyz?.y), z: asNum(g.z ?? xyz?.z),
|
}
|
||||||
radius: Number.isFinite(Number(g.radius)) ? Number(g.radius) : null,
|
})
|
||||||
expiresAt: Number.isFinite(Number(g.expiresAt)) ? Number(g.expiresAt) : null,
|
}
|
||||||
team: pickTeam(g.team ?? g.owner_team ?? g.side ?? null)
|
const buckets: Record<string, string[]> = {
|
||||||
} as Grenade
|
smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
|
||||||
|
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'],
|
||||||
|
he: ['he', 'hegrenade', 'hegrenades', 'explosive'],
|
||||||
|
flash: ['flash', 'flashbang', 'flashbangs'],
|
||||||
|
decoy: ['decoy', 'decoys'],
|
||||||
|
}
|
||||||
|
const out: Grenade[] = []
|
||||||
|
const push = (kind: Grenade['kind'], list: any) => {
|
||||||
|
if (!list) return
|
||||||
|
const arr = Array.isArray(list) ? list : Object.values(list)
|
||||||
|
let i = 0
|
||||||
|
for (const g of arr) {
|
||||||
|
const pos = g?.pos ?? g?.position ?? g?.location
|
||||||
|
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
|
||||||
|
typeof pos === 'string' ? parseVec3String(pos) :
|
||||||
|
(pos || { x: g?.x, y: g?.y, z: g?.z })
|
||||||
|
const id = String(
|
||||||
|
g?.id ?? g?.entityid ?? g?.entindex ??
|
||||||
|
`${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${i++}`
|
||||||
|
)
|
||||||
|
out.push({
|
||||||
|
id, kind,
|
||||||
|
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
|
||||||
|
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : null,
|
||||||
|
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null,
|
||||||
|
team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Objekt mit Buckets (smokes, flashbangs, ...)
|
|
||||||
const buckets: Record<string, string[]> = {
|
|
||||||
smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
|
|
||||||
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'],
|
|
||||||
he: ['he', 'hegrenade', 'hegrenades', 'explosive'],
|
|
||||||
flash: ['flash', 'flashbang', 'flashbangs'],
|
|
||||||
decoy: ['decoy', 'decoys'],
|
|
||||||
}
|
|
||||||
|
|
||||||
const out: Grenade[] = []
|
|
||||||
const push = (kind: Grenade['kind'], list: any) => {
|
|
||||||
if (!list) return
|
|
||||||
const arr = Array.isArray(list) ? list : Object.values(list)
|
|
||||||
let i = 0
|
|
||||||
for (const g of arr) {
|
|
||||||
const pos = g?.pos ?? g?.position ?? g?.location
|
|
||||||
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
|
|
||||||
typeof pos === 'string' ? parseVec3String(pos) :
|
|
||||||
(pos || { x: g?.x, y: g?.y, z: g?.z })
|
|
||||||
|
|
||||||
const id = String(
|
|
||||||
g?.id ??
|
|
||||||
g?.entityid ??
|
|
||||||
g?.entindex ??
|
|
||||||
`${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${i++}`
|
|
||||||
)
|
|
||||||
|
|
||||||
out.push({
|
|
||||||
id,
|
|
||||||
kind,
|
|
||||||
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
|
|
||||||
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : null,
|
|
||||||
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null,
|
|
||||||
team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [kind, keys] of Object.entries(buckets)) {
|
|
||||||
for (const k of keys) {
|
|
||||||
if ((raw as any)[k]) push(kind as Grenade['kind'], (raw as any)[k])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Generischer Fallback: dict {typeKey -> items}
|
|
||||||
if (out.length === 0 && typeof raw === 'object') {
|
|
||||||
for (const [k, v] of Object.entries(raw)) {
|
|
||||||
const kk = k.toLowerCase()
|
|
||||||
const kind =
|
|
||||||
kk.includes('smoke') ? 'smoke' :
|
|
||||||
kk.includes('flash') ? 'flash' :
|
|
||||||
kk.includes('molotov') || kk.includes('inferno') || kk.includes('fire') ? 'molotov' :
|
|
||||||
kk.includes('decoy') ? 'decoy' :
|
|
||||||
kk.includes('he') ? 'he' :
|
|
||||||
'unknown'
|
|
||||||
push(kind as Grenade['kind'], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
for (const [kind, keys] of Object.entries(buckets)) {
|
||||||
const ingestGrenades = (g: any) => {
|
for (const k of keys) if ((raw as any)[k]) push(kind as Grenade['kind'], (raw as any)[k])
|
||||||
const list = normalizeGrenades(g)
|
|
||||||
const next = new Map<string, Grenade>()
|
|
||||||
for (const it of list) next.set(it.id, it)
|
|
||||||
grenadesRef.current = next
|
|
||||||
}
|
}
|
||||||
|
if (out.length === 0 && typeof raw === 'object') {
|
||||||
const dispatch = (m: any) => {
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
if (!m) return
|
const kk = k.toLowerCase()
|
||||||
// Map aus verschiedenen Formaten abgreifen
|
const kind =
|
||||||
if (m.type === 'map' || m.type === 'level' || m.map) {
|
kk.includes('smoke') ? 'smoke' :
|
||||||
const key = m.name || m.map || m.level || m.map?.name
|
kk.includes('flash') ? 'flash' :
|
||||||
if (typeof key === 'string' && key) setActiveMapKey(key.toLowerCase())
|
kk.includes('molotov') || kk.includes('inferno') || kk.includes('fire') ? 'molotov' :
|
||||||
}
|
kk.includes('decoy') ? 'decoy' :
|
||||||
// GSI Zuschauer-Format
|
kk.includes('he') ? 'he' : 'unknown'
|
||||||
if (m.allplayers) handleAllPlayers(m)
|
push(kind as Grenade['kind'], v)
|
||||||
// Tick-Paket deines Servers
|
|
||||||
if (m.type === 'tick') {
|
|
||||||
if (typeof m.map === 'string' && m.map) setActiveMapKey(m.map.toLowerCase())
|
|
||||||
if (Array.isArray(m.players)) for (const p of m.players) dispatch(p)
|
|
||||||
if (m.grenades) ingestGrenades(m.grenades)
|
|
||||||
}
|
|
||||||
// Einzelspieler/Einzelevent
|
|
||||||
if (m.steamId || m.steam_id || m.pos || m.position) upsertPlayer(m)
|
|
||||||
// Grenades ggf. separat
|
|
||||||
if (m.grenades && m.type !== 'tick') ingestGrenades(m.grenades)
|
|
||||||
}
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
if (!alive) return
|
|
||||||
setWsStatus('connecting')
|
|
||||||
ws = new WebSocket(url)
|
|
||||||
|
|
||||||
ws.onopen = () => setWsStatus('open')
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
let msg: any = null
|
|
||||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
|
||||||
|
|
||||||
if (Array.isArray(msg)) {
|
|
||||||
for (const e of msg) dispatch(e)
|
|
||||||
} else if (msg?.type === 'tick' && Array.isArray(msg.players)) {
|
|
||||||
if (typeof msg.map === 'string' && msg.map) setActiveMapKey(msg.map.toLowerCase())
|
|
||||||
for (const p of msg.players) dispatch(p)
|
|
||||||
if (msg.grenades) dispatch({ grenades: msg.grenades })
|
|
||||||
} else if (msg) {
|
|
||||||
if (msg?.map?.name && typeof msg.map.name === 'string') setActiveMapKey(msg.map.name.toLowerCase())
|
|
||||||
dispatch(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onerror = () => setWsStatus('error')
|
|
||||||
ws.onclose = () => {
|
|
||||||
setWsStatus('closed')
|
|
||||||
if (alive) retry = window.setTimeout(connect, 2000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const handleGrenades = (g: any) => {
|
||||||
|
const list = normalizeGrenades(g)
|
||||||
|
const next = new Map<string, Grenade>()
|
||||||
|
for (const it of list) next.set(it.id, it)
|
||||||
|
grenadesRef.current = next
|
||||||
|
}
|
||||||
|
|
||||||
connect()
|
// gemeinsamer flush bei Positionsdaten
|
||||||
return () => {
|
useEffect(() => {
|
||||||
alive = false
|
if (!playersRef.current && !grenadesRef.current) return
|
||||||
if (retry) window.clearTimeout(retry)
|
scheduleFlush()
|
||||||
try { ws?.close(1000, 'radar unmounted') } catch {}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
/* ───────── Overview + Radarbild ───────── */
|
||||||
/* ───────────── Overview laden ───────────── */
|
|
||||||
const [overview, setOverview] = useState<Overview | null>(null)
|
const [overview, setOverview] = useState<Overview | null>(null)
|
||||||
const overviewCandidates = (mapKey: string) => {
|
const overviewCandidates = (mapKey: string) => {
|
||||||
const base = mapKey
|
const base = mapKey
|
||||||
@ -387,7 +352,6 @@ export default function LiveRadar() {
|
|||||||
return () => { cancel = true }
|
return () => { cancel = true }
|
||||||
}, [activeMapKey])
|
}, [activeMapKey])
|
||||||
|
|
||||||
/* ───────────── Radarbild ───────────── */
|
|
||||||
const { folderKey, imageCandidates } = useMemo(() => {
|
const { folderKey, imageCandidates } = useMemo(() => {
|
||||||
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
|
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
|
||||||
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
|
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
|
||||||
@ -439,7 +403,6 @@ export default function LiveRadar() {
|
|||||||
const { posX, posY, scale, rotate = 0 } = overview
|
const { posX, posY, scale, rotate = 0 } = overview
|
||||||
const w = imgSize.w, h = imgSize.h
|
const w = imgSize.w, h = imgSize.h
|
||||||
const cx = w / 2, cy = h / 2
|
const cx = w / 2, cy = h / 2
|
||||||
|
|
||||||
const bases: ((xw: number, yw: number) => { x: number; y: number })[] = [
|
const bases: ((xw: number, yw: number) => { x: number; y: number })[] = [
|
||||||
(xw, yw) => ({ x: (xw - posX) / scale, y: (posY - yw) / scale }),
|
(xw, yw) => ({ x: (xw - posX) / scale, y: (posY - yw) / scale }),
|
||||||
(xw, yw) => ({ x: (posX - xw) / scale, y: (posY - yw) / scale }),
|
(xw, yw) => ({ x: (posX - xw) / scale, y: (posY - yw) / scale }),
|
||||||
@ -481,7 +444,7 @@ export default function LiveRadar() {
|
|||||||
const unitsToPx = useMemo(() => {
|
const unitsToPx = useMemo(() => {
|
||||||
if (!imgSize) return (u: number) => u
|
if (!imgSize) return (u: number) => u
|
||||||
if (overview) {
|
if (overview) {
|
||||||
const scale = overview.scale // world units per pixel
|
const scale = overview.scale
|
||||||
return (u: number) => u / scale
|
return (u: number) => u / scale
|
||||||
}
|
}
|
||||||
const R = 4096
|
const R = 4096
|
||||||
@ -491,21 +454,22 @@ export default function LiveRadar() {
|
|||||||
}, [imgSize, overview])
|
}, [imgSize, overview])
|
||||||
|
|
||||||
/* ───────── Status-Badge ───────── */
|
/* ───────── Status-Badge ───────── */
|
||||||
const WsDot = ({ status }: { status: typeof wsStatus }) => {
|
const WsDot = ({ status, label }: { status: typeof metaWsStatus, label: string }) => {
|
||||||
const color =
|
const color =
|
||||||
status === 'open' ? 'bg-green-500' :
|
status === 'open' ? 'bg-green-500' :
|
||||||
status === 'connecting' ? 'bg-amber-500' :
|
status === 'connecting' ? 'bg-amber-500' :
|
||||||
status === 'error' ? 'bg-red-500' :
|
status === 'error' ? 'bg-red-500' :
|
||||||
'bg-neutral-400'
|
'bg-neutral-400'
|
||||||
const label =
|
const txt =
|
||||||
status === 'open' ? 'verbunden' :
|
status === 'open' ? 'verbunden' :
|
||||||
status === 'connecting' ? 'verbinde…' :
|
status === 'connecting' ? 'verbinde…' :
|
||||||
status === 'error' ? 'Fehler' :
|
status === 'error' ? 'Fehler' :
|
||||||
status === 'closed' ? 'getrennt' : '—'
|
status === 'closed' ? 'getrennt' : '—'
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 text-xs opacity-80">
|
<span className="inline-flex items-center gap-1 text-xs opacity-80">
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
<span className={`inline-block w-2.5 h-2.5 rounded-full ${color}`} />
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ${color}`} />
|
||||||
{label}
|
{txt}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -513,18 +477,36 @@ export default function LiveRadar() {
|
|||||||
/* ───────── Render ───────── */
|
/* ───────── Render ───────── */
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
{/* Head + WS-Badges */}
|
||||||
<div ref={headerRef} className="mb-4 flex items-center justify-between">
|
<div ref={headerRef} className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">Live Radar</h2>
|
<h2 className="text-xl font-semibold">Live Radar</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-sm opacity-80">
|
<div className="text-sm opacity-80">
|
||||||
{activeMapKey
|
{activeMapKey ? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase() : '—'}
|
||||||
? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase()
|
|
||||||
: '—'}
|
|
||||||
</div>
|
</div>
|
||||||
<WsDot status={wsStatus} />
|
<WsDot status={metaWsStatus} label="Meta" />
|
||||||
|
<WsDot status={posWsStatus} label="Pos" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Unsichtbare WS-Clients */}
|
||||||
|
<MetaSocket
|
||||||
|
url={metaUrl}
|
||||||
|
onStatus={setMetaWsStatus}
|
||||||
|
onMap={(k)=> setActiveMapKey(k.toLowerCase())}
|
||||||
|
onPlayersSnapshot={handleMetaPlayersSnapshot}
|
||||||
|
onPlayerJoin={handleMetaPlayerJoin}
|
||||||
|
onPlayerLeave={handleMetaPlayerLeave}
|
||||||
|
/>
|
||||||
|
<PositionsSocket
|
||||||
|
url={posUrl}
|
||||||
|
onStatus={setPosWsStatus}
|
||||||
|
onMap={(k)=> setActiveMapKey(String(k).toLowerCase())}
|
||||||
|
onPlayerUpdate={(p)=> { upsertPlayer(p); scheduleFlush() }}
|
||||||
|
onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }}
|
||||||
|
onGrenades={(g)=> { handleGrenades(g); scheduleFlush() }}
|
||||||
|
/>
|
||||||
|
|
||||||
{!activeMapKey ? (
|
{!activeMapKey ? (
|
||||||
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
|
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
|
||||||
Keine Map erkannt.
|
Keine Map erkannt.
|
||||||
@ -558,48 +540,32 @@ export default function LiveRadar() {
|
|||||||
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
|
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
>
|
>
|
||||||
{/* ───── Grenades layer (unter Spielern) ───── */}
|
{/* Grenades */}
|
||||||
{grenades.map((g) => {
|
{grenades.map((g) => {
|
||||||
const P = worldToPx(g.x, g.y)
|
const P = worldToPx(g.x, g.y)
|
||||||
// typische Radien (world units), falls Server nichts liefert
|
|
||||||
const defaultRadius =
|
const defaultRadius =
|
||||||
g.kind === 'smoke' ? 150 :
|
g.kind === 'smoke' ? 150 :
|
||||||
g.kind === 'molotov'? 120 :
|
g.kind === 'molotov'? 120 :
|
||||||
g.kind === 'he' ? 40 :
|
g.kind === 'he' ? 40 :
|
||||||
g.kind === 'flash' ? 36 :
|
g.kind === 'flash' ? 36 :
|
||||||
g.kind === 'decoy' ? 80 : 60
|
g.kind === 'decoy' ? 80 : 60
|
||||||
|
|
||||||
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defaultRadius))
|
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defaultRadius))
|
||||||
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
|
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
|
||||||
: g.team === 'T' ? UI.nade.teamStrokeT
|
: g.team === 'T' ? UI.nade.teamStrokeT
|
||||||
: UI.nade.stroke
|
: UI.nade.stroke
|
||||||
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
||||||
|
|
||||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
||||||
|
|
||||||
if (g.kind === 'smoke') {
|
if (g.kind === 'smoke') {
|
||||||
return (
|
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
|
||||||
<g key={g.id}>
|
|
||||||
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (g.kind === 'molotov') {
|
if (g.kind === 'molotov') {
|
||||||
return (
|
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
|
||||||
<g key={g.id}>
|
|
||||||
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (g.kind === 'decoy') {
|
if (g.kind === 'decoy') {
|
||||||
return (
|
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
|
||||||
<g key={g.id}>
|
|
||||||
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (g.kind === 'flash') {
|
if (g.kind === 'flash') {
|
||||||
// kleiner Ring + Kreuz
|
|
||||||
return (
|
return (
|
||||||
<g key={g.id}>
|
<g key={g.id}>
|
||||||
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
|
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
|
||||||
@ -609,29 +575,22 @@ export default function LiveRadar() {
|
|||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// HE + unknown: kompakter Punkt
|
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
|
||||||
return (
|
|
||||||
<g key={g.id}>
|
|
||||||
<circle cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* ───── Spieler layer ───── */}
|
{/* Spieler */}
|
||||||
{players
|
{players
|
||||||
.filter(p => p.team === 'CT' || p.team === 'T')
|
.filter(p => p.team === 'CT' || p.team === 'T')
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const A = worldToPx(p.x, p.y)
|
const A = worldToPx(p.x, p.y)
|
||||||
const base = Math.min(imgSize.w, imgSize.h)
|
const base = Math.min(imgSize.w, imgSize.h)
|
||||||
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
|
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
|
||||||
|
|
||||||
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
|
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
|
||||||
const stroke = UI.player.stroke
|
const stroke = UI.player.stroke
|
||||||
const strokeW = Math.max(1, r * UI.player.lineWidthRel)
|
const strokeW = Math.max(1, r * UI.player.lineWidthRel)
|
||||||
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
|
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
|
||||||
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
|
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
|
||||||
|
|
||||||
// Blickrichtung aus yaw (Grad)
|
|
||||||
let dxp = 0, dyp = 0
|
let dxp = 0, dyp = 0
|
||||||
if (Number.isFinite(p.yaw as number)) {
|
if (Number.isFinite(p.yaw as number)) {
|
||||||
const yawRad = (Number(p.yaw) * Math.PI) / 180
|
const yawRad = (Number(p.yaw) * Math.PI) / 180
|
||||||
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',
|
'expired-sharecode',
|
||||||
'team-invite-revoked',
|
'team-invite-revoked',
|
||||||
'map-vote-updated',
|
'map-vote-updated',
|
||||||
'match-meta-updated',
|
|
||||||
'map-vote-admin-edit',
|
'map-vote-admin-edit',
|
||||||
'match-created',
|
'match-created',
|
||||||
'matches-updated',
|
'matches-updated',
|
||||||
'match-deleted',
|
'match-deleted',
|
||||||
'match-updated',
|
|
||||||
'match-lineup-updated',
|
'match-lineup-updated',
|
||||||
'user-status-updated',
|
'user-status-updated',
|
||||||
'match-ready',
|
'match-ready',
|
||||||
@ -82,7 +80,6 @@ export const MATCH_EVENTS = makeEventSet([
|
|||||||
'matches-updated',
|
'matches-updated',
|
||||||
'match-deleted',
|
'match-deleted',
|
||||||
'match-lineup-updated',
|
'match-lineup-updated',
|
||||||
'match-updated',
|
|
||||||
'map-vote-updated',
|
'map-vote-updated',
|
||||||
'map-vote-admin-edit',
|
'map-vote-admin-edit',
|
||||||
'match-ready',
|
'match-ready',
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import LiveRadar from '@/app/components/LiveRadar'
|
// /app/match-details/[matchId]/radar/page.tsx
|
||||||
|
|
||||||
|
import LiveRadar from '@/app/components/radar/LiveRadar'
|
||||||
|
|
||||||
export default function RadarPage({ params }: { params: { matchId: string } }) {
|
export default function RadarPage({ params }: { params: { matchId: string } }) {
|
||||||
return <LiveRadar matchId={params.matchId} />
|
return <LiveRadar />
|
||||||
}
|
}
|
||||||
@ -34,6 +34,7 @@ export type Match = {
|
|||||||
status : 'not_started' | 'in_progress' | 'completed' | null
|
status : 'not_started' | 'in_progress' | 'completed' | null
|
||||||
opensAt: string | null
|
opensAt: string | null
|
||||||
isOpen : boolean | null
|
isOpen : boolean | null
|
||||||
|
leadMinutes?: number | null
|
||||||
locked?: boolean | null
|
locked?: boolean | null
|
||||||
steps? : MapVoteStep[]
|
steps? : MapVoteStep[]
|
||||||
} | null
|
} | null
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user