updated
5
.env
@ -22,4 +22,7 @@ PTERO_PANEL_URL=https://panel.ironieopen.de
|
||||
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
|
||||
PTERO_SERVER_SFTP_USER=army.37a11489
|
||||
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
|
||||
BIN
public/assets/img/radar/de_ancient/de_ancient_radar_psd.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
public/assets/img/radar/de_ancient/de_ancient_v1_radar_psd.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
public/assets/img/radar/de_anubis/de_anubis_radar_psd.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/assets/img/radar/de_dust2/de_dust2_radar_psd.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
public/assets/img/radar/de_inferno/de_inferno_radar_psd.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
public/assets/img/radar/de_mirage/de_mirage_radar_psd.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
public/assets/img/radar/de_nuke/de_nuke_lower_radar_psd.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
public/assets/img/radar/de_nuke/de_nuke_radar_psd.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
public/assets/img/radar/de_overpass/de_overpass_radar_psd.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
public/assets/img/radar/de_train/de_train_lower_radar_psd.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/assets/img/radar/de_train/de_train_radar_psd.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 121 KiB |
BIN
public/assets/img/radar/de_vertigo/de_vertigo_radar_psd.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
202
src/app/components/LiveRadar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -78,14 +78,12 @@ export default function MapVotePanel({ match }: Props) {
|
||||
if (lastEvent.payload?.matchId !== match.id) return
|
||||
|
||||
const fm = lastEvent.payload?.firstMap ?? {}
|
||||
showWithDelay(
|
||||
{
|
||||
matchId: match.id,
|
||||
mapLabel: fm?.label ?? 'Erste Map',
|
||||
mapBg: fm?.bg ?? '/assets/img/maps/cs2.webp',
|
||||
},
|
||||
3000
|
||||
)
|
||||
showWithDelay({
|
||||
matchId: match.id,
|
||||
mapLabel: fm?.label ?? 'Erste Map',
|
||||
mapBg: fm?.bg ?? '/assets/img/maps/cs2.webp',
|
||||
nextHref: `/match-details/${match.id}/radar`,
|
||||
}, 3000)
|
||||
}, [lastEvent, match.id, showWithDelay])
|
||||
|
||||
/* -------- Data load (initial + SSE refresh) -------- */
|
||||
|
||||
@ -1,37 +1,39 @@
|
||||
'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 { useSSEStore } from '@/app/lib/useSSEStore'
|
||||
import MatchReadyOverlay from './MatchReadyOverlay'
|
||||
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore'
|
||||
|
||||
/**
|
||||
* Erwartet ein SSE-Event 'map-vote-updated' bzw. 'match-ready' mit payload:
|
||||
* { matchId: string, locked: boolean, bestOf: number, steps: [...], mapVisuals: { [k]: {label, bg} } }
|
||||
*
|
||||
* 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.
|
||||
* Erwartet SSE-Events:
|
||||
* - 'match-ready' { matchId, firstMap:{label,bg}, participants:string[] }
|
||||
* - 'map-vote-updated' { matchId, locked, bestOf, steps, mapVisuals, teams:{teamA,teamB} }
|
||||
*/
|
||||
|
||||
export default function ReadyOverlayHost() {
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const mySteamId = session?.user?.steamId ?? null
|
||||
const { lastEvent } = useSSEStore()
|
||||
|
||||
const { open, data, showWithDelay, hide } = useReadyOverlayStore()
|
||||
|
||||
// lokal gemerkte "bereits akzeptiert"-Flags pro Match
|
||||
// --- LocalStorage-Flag: pro Match nur einmal anzeigen ---
|
||||
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) => {
|
||||
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) {
|
||||
// payload: { matchId, locked, bestOf, steps, mapVisuals, teams: { teamA:{players,leader}, teamB:{players,leader} } }
|
||||
const matchId: string | undefined = payload?.matchId
|
||||
if (!matchId) return null
|
||||
|
||||
@ -40,7 +42,6 @@ export default function ReadyOverlayHost() {
|
||||
const steps: any[] = Array.isArray(payload?.steps) ? payload.steps : []
|
||||
const mapVisuals = payload?.mapVisuals ?? {}
|
||||
|
||||
// Teilnehmer ermitteln (aus votes-Teams oder Fallbacks)
|
||||
const playersA = payload?.teams?.teamA?.players ?? []
|
||||
const playersB = payload?.teams?.teamB?.players ?? []
|
||||
const participants: string[] = [
|
||||
@ -48,39 +49,44 @@ export default function ReadyOverlayHost() {
|
||||
...playersB.map((p: any) => p?.steamId).filter(Boolean),
|
||||
]
|
||||
|
||||
// Picks + Decider mit Map
|
||||
const chosen = steps.filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
|
||||
const chosen = steps.filter(
|
||||
(s) => (s.action === 'pick' || s.action === 'decider') && s.map
|
||||
)
|
||||
const allChosen = locked && chosen.length >= bestOf
|
||||
|
||||
if (!allChosen) return null
|
||||
|
||||
const first = chosen[0]
|
||||
const key = first?.map
|
||||
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 {
|
||||
matchId,
|
||||
firstMap: { key, label, bg },
|
||||
participants,
|
||||
}
|
||||
return { 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(() => {
|
||||
if (!lastEvent || !mySteamId) return
|
||||
|
||||
console.log("Empfangen: ", lastEvent);
|
||||
|
||||
if (lastEvent.type === 'match-ready') {
|
||||
const m = lastEvent.payload?.matchId
|
||||
const participants: string[] = lastEvent.payload?.participants ?? []
|
||||
if (!m || !participants.includes(mySteamId)) return
|
||||
if (isAccepted(m)) return
|
||||
if (!m || !participants.includes(mySteamId) || isAccepted(m)) return
|
||||
|
||||
const label = lastEvent.payload?.firstMap?.label ?? '?'
|
||||
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
|
||||
}
|
||||
|
||||
@ -88,14 +94,21 @@ export default function ReadyOverlayHost() {
|
||||
const summary = deriveReadySummary(lastEvent.payload)
|
||||
if (!summary) return
|
||||
const { matchId: m, firstMap, participants } = summary
|
||||
if (!participants.includes(mySteamId)) return
|
||||
if (isAccepted(m)) return
|
||||
// 3s Delay:
|
||||
showWithDelay({ matchId: m, mapLabel: firstMap.label, mapBg: firstMap.bg }, 3000)
|
||||
if (!participants.includes(mySteamId) || isAccepted(m)) return
|
||||
|
||||
showWithDelay(
|
||||
{
|
||||
matchId: m,
|
||||
mapLabel: firstMap.label,
|
||||
mapBg: firstMap.bg,
|
||||
nextHref: `/match-details/${m}/radar`, // <- WICHTIG
|
||||
},
|
||||
3000
|
||||
)
|
||||
}
|
||||
}, [lastEvent, mySteamId, showWithDelay])
|
||||
|
||||
// Falls Voting zurückgesetzt wird: Overlay schließen + Flag löschen
|
||||
// --- Reset-Event: Overlay schließen & Flag löschen ---
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
if (lastEvent.type === 'map-vote-reset') {
|
||||
@ -115,10 +128,11 @@ export default function ReadyOverlayHost() {
|
||||
mapLabel={data.mapLabel}
|
||||
mapBg={data.mapBg}
|
||||
onAccept={async () => {
|
||||
// optional: Backend benachrichtigen
|
||||
// optional: Backend informieren
|
||||
// await fetch(`/api/matches/${data.matchId}/ready-accept`, { method: 'POST' }).catch(() => {})
|
||||
markAccepted(data.matchId)
|
||||
hide()
|
||||
router.push(data.nextHref ?? `/match-details/${data.matchId}/radar`) // <- WICHTIG
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -6,12 +6,13 @@ type ReadyOverlayData = {
|
||||
matchId: string
|
||||
mapLabel: string
|
||||
mapBg: string
|
||||
nextHref?: string // Zielroute nach "ACCEPT"
|
||||
}
|
||||
|
||||
type State = {
|
||||
open: boolean
|
||||
data: ReadyOverlayData | null
|
||||
showAt?: number | null // für 3s Delay
|
||||
showAt?: number | null
|
||||
showWithDelay: (data: ReadyOverlayData, delayMs: number) => void
|
||||
hide: () => void
|
||||
}
|
||||
@ -23,7 +24,6 @@ export const useReadyOverlayStore = create<State>((set) => ({
|
||||
showWithDelay: (data, delayMs) => {
|
||||
const showAt = Date.now() + Math.max(0, delayMs)
|
||||
set({ data, showAt })
|
||||
// kleiner Ticker um nach delayMs zu öffnen (ohne extra setTimeout-Leak)
|
||||
const step = () => {
|
||||
const t = Date.now()
|
||||
if (t >= showAt) {
|
||||
|
||||
5
src/app/match-details/[matchId]/radar/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import LiveRadar from '@/app/components/LiveRadar'
|
||||
|
||||
export default function RadarPage({ params }: { params: { matchId: string } }) {
|
||||
return <LiveRadar matchId={params.matchId} />
|
||||
}
|
||||