This commit is contained in:
Linrador 2025-08-18 23:49:15 +02:00
parent 1b5cd58f7f
commit 640505bc1f
19 changed files with 268 additions and 46 deletions

3
.env
View File

@ -23,3 +23,6 @@ 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_URL=wss://cs2.ironieopen.de:8081/telemetry
NEXT_PUBLIC_CS2_WS_HOST=cs2.ironieopen.de
NEXT_PUBLIC_CS2_WS_PORT=8081

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -0,0 +1,202 @@
// /app/components/LiveRadar.tsx
'use client'
import { useEffect, useMemo, useState } from 'react'
import Button from './Button'
import LoadingSpinner from './LoadingSpinner'
type Props = { matchId: string }
type ApiStep = { action: 'ban'|'pick'|'decider', map?: string | null }
type ApiResponse = {
steps: ApiStep[]
mapVisuals?: Record<string, { label: string; bg: string }>
}
export default function LiveRadar({ matchId }: Props) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<ApiResponse | null>(null)
// --- WS Status für Header ---
const [wsStatus, setWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
// 1) MapVote laden (welche Map wurde gewählt?)
useEffect(() => {
let canceled = false
const load = async () => {
setLoading(true); setError(null)
try {
const r = await fetch(`/api/matches/${matchId}/mapvote`, { cache: 'no-store' })
if (!r.ok) {
const j = await r.json().catch(() => ({}))
throw new Error(j?.message || 'Laden fehlgeschlagen')
}
const json = await r.json()
if (!Array.isArray(json?.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
if (!canceled) setData(json)
} catch (e: any) {
if (!canceled) setError(e?.message ?? 'Unbekannter Fehler')
} finally {
if (!canceled) setLoading(false)
}
}
load()
return () => { canceled = true }
}, [matchId])
// 2) Beim Betreten des Radars mit dem CS2-WS-Server verbinden und alles loggen
useEffect(() => {
if (typeof window === 'undefined') return
// Priorität: explizite URL > Host/Port > Fallback auf aktuelle Hostname:8081
const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL
const host = process.env.NEXT_PUBLIC_CS2_WS_HOST || window.location.hostname
const port = process.env.NEXT_PUBLIC_CS2_WS_PORT || '8081'
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = explicit || `${proto}://${host}:${port}`
let alive = true
let ws: WebSocket | null = null
let retryTimer: number | null = null
const connect = () => {
if (!alive) return
setWsStatus('connecting')
ws = new WebSocket(url)
ws.onopen = () => {
setWsStatus('open')
console.info('[cs2-ws] connected →', url)
}
ws.onmessage = (ev) => {
// Rohdaten + JSON-parsed in der Konsole anzeigen
const raw = ev.data
try {
const parsed = JSON.parse(raw as string)
console.log('[cs2-ws] message (json):', parsed)
} catch {
console.log('[cs2-ws] message (raw):', raw)
}
}
ws.onerror = (err) => {
setWsStatus('error')
console.error('[cs2-ws] error:', err)
}
ws.onclose = (ev) => {
console.warn('[cs2-ws] closed:', ev.code, ev.reason)
setWsStatus('closed')
if (alive) {
// simpler Reconnect nach 2s
retryTimer = window.setTimeout(connect, 2000)
}
}
}
connect()
return () => {
alive = false
if (retryTimer) window.clearTimeout(retryTimer)
try { ws?.close(1000, 'radar unmounted') } catch {}
}
}, []) // nur einmal beim Mount auf /radar verbinden
// Erste gespielte Map (Pick/Decider in Anzeige-Reihenfolge)
const firstMapKey = useMemo(() => {
const chosen = (data?.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
return chosen[0]?.map ?? null
}, [data])
const mapLabel = useMemo(() => {
if (!firstMapKey) return 'Unbekannte Map'
return data?.mapVisuals?.[firstMapKey]?.label ?? firstMapKey
}, [data, firstMapKey])
// Radar-Datei(en)
const { folderKey, candidates } = useMemo(() => {
if (!firstMapKey) return { folderKey: null as string | null, candidates: [] as string[] }
const key = firstMapKey.startsWith('de_') ? firstMapKey.slice(3) : firstMapKey
const base = `/assets/img/radar/${key}`
const list = [
`${base}/de_${key}_radar_psd.png`,
`${base}/de_${key}_lower_radar_psd.png`,
`${base}/de_${key}_v1_radar_psd.png`,
`${base}/de_${key}_radar.png`, // optionaler Fallback
]
return { folderKey: key, candidates: list }
}, [firstMapKey])
const [srcIdx, setSrcIdx] = useState(0)
useEffect(() => { setSrcIdx(0) }, [folderKey])
const currentSrc = candidates[srcIdx]
// kleines Badge für WS-Status
const WsDot = ({ status }: { status: typeof wsStatus }) => {
const color =
status === 'open' ? 'bg-green-500' :
status === 'connecting' ? 'bg-amber-500' :
status === 'error' ? 'bg-red-500' :
'bg-neutral-400'
const label =
status === 'open' ? 'verbunden' :
status === 'connecting' ? 'verbinde…' :
status === 'error' ? 'Fehler' :
status === 'closed' ? 'getrennt' : '—'
return (
<span className="inline-flex items-center gap-1 text-xs opacity-80">
<span className={`inline-block w-2.5 h-2.5 rounded-full ${color}`} />
{label}
</span>
)
}
return (
<div className="p-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Live Radar</h2>
<div className="flex items-center gap-3">
<div className="text-sm opacity-80">{mapLabel}</div>
<WsDot status={wsStatus} />
</div>
</div>
{loading ? (
<div className="p-8 flex justify-center"><LoadingSpinner /></div>
) : error ? (
<div className="p-4 text-red-600">{error}</div>
) : !firstMapKey ? (
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
Noch keine gespielte Map gefunden.
</div>
) : (
<div className="w-full">
{/* großes Radar-Bild */}
<div className="relative w-full max-w-5xl mx-auto rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800">
{currentSrc ? (
<img
key={currentSrc}
src={currentSrc}
alt={mapLabel}
className="w-full h-auto block"
onError={() => {
if (srcIdx < candidates.length - 1) setSrcIdx(i => i + 1)
else setError('Radar-Grafik nicht gefunden.')
}}
/>
) : (
<div className="p-6 text-center">Keine Radar-Grafik gefunden.</div>
)}
</div>
<div className="mt-2 text-xs opacity-70 text-center">
Quelle: <code className="px-1">{currentSrc ?? '—'}</code>
</div>
</div>
)}
</div>
)
}

View File

@ -78,14 +78,12 @@ export default function MapVotePanel({ match }: Props) {
if (lastEvent.payload?.matchId !== match.id) return if (lastEvent.payload?.matchId !== match.id) return
const fm = lastEvent.payload?.firstMap ?? {} const fm = lastEvent.payload?.firstMap ?? {}
showWithDelay( showWithDelay({
{
matchId: match.id, matchId: match.id,
mapLabel: fm?.label ?? 'Erste Map', mapLabel: fm?.label ?? 'Erste Map',
mapBg: fm?.bg ?? '/assets/img/maps/cs2.webp', mapBg: fm?.bg ?? '/assets/img/maps/cs2.webp',
}, nextHref: `/match-details/${match.id}/radar`,
3000 }, 3000)
)
}, [lastEvent, match.id, showWithDelay]) }, [lastEvent, match.id, showWithDelay])
/* -------- Data load (initial + SSE refresh) -------- */ /* -------- Data load (initial + SSE refresh) -------- */

View File

@ -1,37 +1,39 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/app/lib/useSSEStore'
import MatchReadyOverlay from './MatchReadyOverlay' import MatchReadyOverlay from './MatchReadyOverlay'
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore' import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore'
/** /**
* Erwartet ein SSE-Event 'map-vote-updated' bzw. 'match-ready' mit payload: * Erwartet SSE-Events:
* { matchId: string, locked: boolean, bestOf: number, steps: [...], mapVisuals: { [k]: {label, bg} } } * - 'match-ready' { matchId, firstMap:{label,bg}, participants:string[] }
* * - 'map-vote-updated' { matchId, locked, bestOf, steps, mapVisuals, teams:{teamA,teamB} }
* Falls dein Backend leichter anpassbar ist: schicke einfach ein schlankes 'match-ready' Event mit:
* { matchId, firstMap: { key, label, bg }, participants: string[] }
* Dann sparst du dir den Client-seitigen Ableitungscode unten.
*/ */
export default function ReadyOverlayHost() { export default function ReadyOverlayHost() {
const router = useRouter()
const { data: session } = useSession() const { data: session } = useSession()
const mySteamId = session?.user?.steamId ?? null const mySteamId = session?.user?.steamId ?? null
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const { open, data, showWithDelay, hide } = useReadyOverlayStore() const { open, data, showWithDelay, hide } = useReadyOverlayStore()
// lokal gemerkte "bereits akzeptiert"-Flags pro Match // --- LocalStorage-Flag: pro Match nur einmal anzeigen ---
const isAccepted = (matchId: string) => const isAccepted = (matchId: string) =>
typeof window !== 'undefined' && window.localStorage.getItem(`match:${matchId}:readyAccepted`) === '1' typeof window !== 'undefined' &&
window.localStorage.getItem(`match:${matchId}:readyAccepted`) === '1'
const markAccepted = (matchId: string) => { const markAccepted = (matchId: string) => {
if (typeof window !== 'undefined') window.localStorage.setItem(`match:${matchId}:readyAccepted`, '1') if (typeof window !== 'undefined') {
window.localStorage.setItem(`match:${matchId}:readyAccepted`, '1')
}
} }
// Helfer: aus einem map-vote-updated Payload die erste Map & Teilnehmer ableiten // --- Ableitung firstMap + participants aus 'map-vote-updated' ---
function deriveReadySummary(payload: any) { function deriveReadySummary(payload: any) {
// payload: { matchId, locked, bestOf, steps, mapVisuals, teams: { teamA:{players,leader}, teamB:{players,leader} } }
const matchId: string | undefined = payload?.matchId const matchId: string | undefined = payload?.matchId
if (!matchId) return null if (!matchId) return null
@ -40,7 +42,6 @@ export default function ReadyOverlayHost() {
const steps: any[] = Array.isArray(payload?.steps) ? payload.steps : [] const steps: any[] = Array.isArray(payload?.steps) ? payload.steps : []
const mapVisuals = payload?.mapVisuals ?? {} const mapVisuals = payload?.mapVisuals ?? {}
// Teilnehmer ermitteln (aus votes-Teams oder Fallbacks)
const playersA = payload?.teams?.teamA?.players ?? [] const playersA = payload?.teams?.teamA?.players ?? []
const playersB = payload?.teams?.teamB?.players ?? [] const playersB = payload?.teams?.teamB?.players ?? []
const participants: string[] = [ const participants: string[] = [
@ -48,39 +49,44 @@ export default function ReadyOverlayHost() {
...playersB.map((p: any) => p?.steamId).filter(Boolean), ...playersB.map((p: any) => p?.steamId).filter(Boolean),
] ]
// Picks + Decider mit Map const chosen = steps.filter(
const chosen = steps.filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) (s) => (s.action === 'pick' || s.action === 'decider') && s.map
)
const allChosen = locked && chosen.length >= bestOf const allChosen = locked && chosen.length >= bestOf
if (!allChosen) return null if (!allChosen) return null
const first = chosen[0] const first = chosen[0]
const key = first?.map const key = first?.map
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?' const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp' const bg = key
? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`)
: '/assets/img/maps/cs2.webp'
return { return { matchId, firstMap: { key, label, bg }, participants }
matchId,
firstMap: { key, label, bg },
participants,
}
} }
// SSE-Events beobachten (map-vote-updated oder match-ready) // --- SSE: 'match-ready' / 'map-vote-updated' öffnen das Overlay ---
useEffect(() => { useEffect(() => {
if (!lastEvent || !mySteamId) return if (!lastEvent || !mySteamId) return
console.log("Empfangen: ", lastEvent);
if (lastEvent.type === 'match-ready') { if (lastEvent.type === 'match-ready') {
const m = lastEvent.payload?.matchId const m = lastEvent.payload?.matchId
const participants: string[] = lastEvent.payload?.participants ?? [] const participants: string[] = lastEvent.payload?.participants ?? []
if (!m || !participants.includes(mySteamId)) return if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
if (isAccepted(m)) return
const label = lastEvent.payload?.firstMap?.label ?? '?' const label = lastEvent.payload?.firstMap?.label ?? '?'
const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp' const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp'
// 3s Delay:
showWithDelay({ matchId: m, mapLabel: label, mapBg: bg }, 3000) // 3s Delay + Zielroute mitgeben:
showWithDelay(
{
matchId: m,
mapLabel: label,
mapBg: bg,
nextHref: `/match-details/${m}/radar`, // <- WICHTIG
},
3000
)
return return
} }
@ -88,14 +94,21 @@ export default function ReadyOverlayHost() {
const summary = deriveReadySummary(lastEvent.payload) const summary = deriveReadySummary(lastEvent.payload)
if (!summary) return if (!summary) return
const { matchId: m, firstMap, participants } = summary const { matchId: m, firstMap, participants } = summary
if (!participants.includes(mySteamId)) return if (!participants.includes(mySteamId) || isAccepted(m)) return
if (isAccepted(m)) return
// 3s Delay: showWithDelay(
showWithDelay({ matchId: m, mapLabel: firstMap.label, mapBg: firstMap.bg }, 3000) {
matchId: m,
mapLabel: firstMap.label,
mapBg: firstMap.bg,
nextHref: `/match-details/${m}/radar`, // <- WICHTIG
},
3000
)
} }
}, [lastEvent, mySteamId, showWithDelay]) }, [lastEvent, mySteamId, showWithDelay])
// Falls Voting zurückgesetzt wird: Overlay schließen + Flag löschen // --- Reset-Event: Overlay schließen & Flag löschen ---
useEffect(() => { useEffect(() => {
if (!lastEvent) return if (!lastEvent) return
if (lastEvent.type === 'map-vote-reset') { if (lastEvent.type === 'map-vote-reset') {
@ -115,10 +128,11 @@ export default function ReadyOverlayHost() {
mapLabel={data.mapLabel} mapLabel={data.mapLabel}
mapBg={data.mapBg} mapBg={data.mapBg}
onAccept={async () => { onAccept={async () => {
// optional: Backend benachrichtigen // optional: Backend informieren
// await fetch(`/api/matches/${data.matchId}/ready-accept`, { method: 'POST' }).catch(() => {}) // await fetch(`/api/matches/${data.matchId}/ready-accept`, { method: 'POST' }).catch(() => {})
markAccepted(data.matchId) markAccepted(data.matchId)
hide() hide()
router.push(data.nextHref ?? `/match-details/${data.matchId}/radar`) // <- WICHTIG
}} }}
/> />
) )

View File

@ -6,12 +6,13 @@ type ReadyOverlayData = {
matchId: string matchId: string
mapLabel: string mapLabel: string
mapBg: string mapBg: string
nextHref?: string // Zielroute nach "ACCEPT"
} }
type State = { type State = {
open: boolean open: boolean
data: ReadyOverlayData | null data: ReadyOverlayData | null
showAt?: number | null // für 3s Delay showAt?: number | null
showWithDelay: (data: ReadyOverlayData, delayMs: number) => void showWithDelay: (data: ReadyOverlayData, delayMs: number) => void
hide: () => void hide: () => void
} }
@ -23,7 +24,6 @@ export const useReadyOverlayStore = create<State>((set) => ({
showWithDelay: (data, delayMs) => { showWithDelay: (data, delayMs) => {
const showAt = Date.now() + Math.max(0, delayMs) const showAt = Date.now() + Math.max(0, delayMs)
set({ data, showAt }) set({ data, showAt })
// kleiner Ticker um nach delayMs zu öffnen (ohne extra setTimeout-Leak)
const step = () => { const step = () => {
const t = Date.now() const t = Date.now()
if (t >= showAt) { if (t >= showAt) {

View File

@ -0,0 +1,5 @@
import LiveRadar from '@/app/components/LiveRadar'
export default function RadarPage({ params }: { params: { matchId: string } }) {
return <LiveRadar matchId={params.matchId} />
}