updated gamebanner
This commit is contained in:
parent
2dc92c55c2
commit
72f9fcb8f6
33
package-lock.json
generated
33
package-lock.json
generated
@ -44,6 +44,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
|
"swr": "^2.3.6",
|
||||||
"undici": "^7.15.0",
|
"undici": "^7.15.0",
|
||||||
"vanilla-calendar-pro": "^3.0.4",
|
"vanilla-calendar-pro": "^3.0.4",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
@ -3534,6 +3535,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/destr": {
|
"node_modules/destr": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
@ -7628,6 +7638,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.6.tgz",
|
||||||
|
"integrity": "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tabbable": {
|
"node_modules/tabbable": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||||
@ -8019,6 +8042,16 @@
|
|||||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@ -50,6 +50,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
|
"swr": "^2.3.6",
|
||||||
"undici": "^7.15.0",
|
"undici": "^7.15.0",
|
||||||
"vanilla-calendar-pro": "^3.0.4",
|
"vanilla-calendar-pro": "^3.0.4",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
|
|||||||
@ -1,110 +1,227 @@
|
|||||||
// src/app/[locale]/components/GameBanner.tsx
|
// /src/app/[locale]/components/GameBanner.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, {useEffect, useRef, useState} from 'react'
|
import React, {useEffect, useMemo, useRef, useState} from 'react'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import {useSession} from 'next-auth/react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
||||||
import {MAP_OPTIONS} from '@/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
|
|
||||||
export type GameBannerVariant = 'connected' | 'disconnected'
|
type LiveCfg = {
|
||||||
|
activeMatchId: string | null
|
||||||
type Props = {
|
activeMapKey: string | null
|
||||||
variant: GameBannerVariant
|
activeMapLabel: string | null
|
||||||
visible: boolean
|
activeMapBg: string | null
|
||||||
zIndex?: number
|
activeParticipants: string[] | null
|
||||||
connectedCount: number
|
activeSince: string | null
|
||||||
totalExpected: number
|
bannerExpiresAt?: string | null
|
||||||
onReconnect: () => void
|
updatedAt?: string
|
||||||
onDisconnect?: () => void
|
|
||||||
serverLabel?: string
|
|
||||||
mapKey?: string
|
|
||||||
mapLabel?: string
|
|
||||||
phase?: string
|
|
||||||
score?: string
|
|
||||||
inline?: boolean
|
|
||||||
connectUri?: string
|
|
||||||
missingCount?: number
|
|
||||||
bgUrl?: string
|
|
||||||
iconUrl?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Variant = 'connected' | 'disconnected'
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
|
||||||
|
|
||||||
/* ---------- helpers ---------- */
|
/* ---------- helpers ---------- */
|
||||||
const hashStr = (s: string) => {
|
const hashStr = (s: string) => { let h = 5381; for (let i=0;i<s.length;i++) h=((h<<5)+h)+s.charCodeAt(i); return h|0 }
|
||||||
let h = 5381
|
const pickMapImageFromOptions = (mapKey?: string) => {
|
||||||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h) + s.charCodeAt(i)
|
|
||||||
return h | 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickMapImageFromOptions(mapKey?: string): string | null {
|
|
||||||
if (!mapKey) return null
|
if (!mapKey) return null
|
||||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
||||||
if (!opt || !opt.images?.length) return null
|
if (!opt || !opt.images?.length) return null
|
||||||
const idx = Math.abs(hashStr(mapKey)) % opt.images.length
|
const idx = Math.abs(hashStr(mapKey)) % opt.images.length
|
||||||
return opt.images[idx] ?? null
|
return opt.images[idx] ?? null
|
||||||
}
|
}
|
||||||
|
const pickMapIcon = (mapKey?: string) => {
|
||||||
function pickMapIcon(mapKey?: string): string | null {
|
|
||||||
if (!mapKey) return null
|
if (!mapKey) return null
|
||||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
||||||
return opt?.icon ?? null
|
return opt?.icon ?? null
|
||||||
}
|
}
|
||||||
|
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
|
||||||
// Banner auf mobilen Bildschirmen gar nicht rendern (unter sm)
|
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
|
||||||
function useIsSmDown() {
|
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
||||||
const [smDown, setSmDown] = useState(false)
|
const h = (host ?? '').trim() || '127.0.0.1'
|
||||||
useEffect(() => {
|
const p = (port ?? '').trim() || '8081'
|
||||||
const mq = window.matchMedia('(max-width: 639.98px)')
|
const pa = (path ?? '').trim() || '/telemetry'
|
||||||
setSmDown(mq.matches)
|
const sch = (scheme ?? '').toLowerCase()
|
||||||
const onChange = (e: MediaQueryListEvent) => setSmDown(e.matches)
|
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
|
||||||
mq.addEventListener('change', onChange)
|
const useWss = sch === 'wss' || (sch !== 'ws' && (p === '443' || pageHttps))
|
||||||
return () => mq.removeEventListener('change', onChange)
|
const proto = useWss ? 'wss' : 'ws'
|
||||||
}, [])
|
const portPart = p === '80' || p === '443' ? '' : `:${p}`
|
||||||
return smDown
|
return `${proto}://${h}${portPart}${pa}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- component ---------- */
|
/* ---------- component ---------- */
|
||||||
export default function GameBanner(props: Props) {
|
export default function GameBanner() {
|
||||||
const {
|
|
||||||
variant, visible, zIndex = 9999, connectedCount, totalExpected,
|
|
||||||
onReconnect, onDisconnect, serverLabel, mapKey, mapLabel, phase, score, inline = false,
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
|
||||||
const setBannerPx = useGameBannerStore(s => s.setGameBannerPx)
|
|
||||||
const isSmDown = useIsSmDown()
|
|
||||||
const tGameBanner = useTranslations('game-banner')
|
const tGameBanner = useTranslations('game-banner')
|
||||||
|
const setBannerPx = useGameBannerStore(s => s.setGameBannerPx)
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const phaseStr = String(phase ?? 'unknown').toLowerCase()
|
// Session → meId
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const meId = session?.user?.steamId ? String(session.user.steamId) : null
|
||||||
|
|
||||||
const shownConnected = Math.max(0, connectedCount - (props.missingCount ?? 0))
|
// Live-Config
|
||||||
|
const { data } = useSWR<{ ok: boolean; data: LiveCfg | null }>(
|
||||||
|
'/api/cs2/server/live',
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
revalidateOnMount: true,
|
||||||
|
revalidateIfStale: true,
|
||||||
|
dedupingInterval: 0,
|
||||||
|
refreshInterval: 30_000,
|
||||||
|
fallbackData: { ok: true, data: null },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const cfg = data?.data ?? null
|
||||||
|
|
||||||
// Ziel-Sichtbarkeit anhand Props/Viewport/Phase
|
// WS url
|
||||||
const targetShow = !isSmDown && visible && phaseStr !== 'unknown'
|
const url = useMemo(
|
||||||
|
() => makeWsUrl(
|
||||||
|
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_HOST,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_PORT,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_PATH,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_SCHEME
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
// Animations-Dauer (muss zu den CSS-Klassen passen)
|
// Local live-state
|
||||||
|
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
|
||||||
|
const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null })
|
||||||
|
const [connectHref, setConnectHref] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// optional: Connect-URI vom Server holen
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/cs2/server', { cache: 'no-store' })
|
||||||
|
if (!r.ok) return
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.connectHref) setConnectHref(j.connectHref)
|
||||||
|
} catch {}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// WS lifecycle
|
||||||
|
const aliveRef = useRef(true)
|
||||||
|
const retryRef = useRef<number | null>(null)
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
aliveRef.current = true
|
||||||
|
|
||||||
|
const connectOnce = () => {
|
||||||
|
if (!aliveRef.current || !url) return
|
||||||
|
if (wsRef.current && (
|
||||||
|
wsRef.current.readyState === WebSocket.OPEN ||
|
||||||
|
wsRef.current.readyState === WebSocket.CONNECTING
|
||||||
|
)) return
|
||||||
|
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') console.debug('[GameBanner] ws open')
|
||||||
|
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||||
|
}
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') console.debug('[GameBanner] ws error')
|
||||||
|
}
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') console.debug('[GameBanner] ws closed')
|
||||||
|
wsRef.current = null
|
||||||
|
if (aliveRef.current && !retryRef.current) {
|
||||||
|
retryRef.current = window.setTimeout(() => { retryRef.current = null; connectOnce() }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg: any = null
|
||||||
|
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||||
|
const ids = msg.players.map(sidOf).filter(Boolean)
|
||||||
|
setTelemetrySet(toSet(ids))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.type === 'player_join' && msg.player) {
|
||||||
|
const sid = sidOf(msg.player); if (!sid) return
|
||||||
|
setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.type === 'player_leave') {
|
||||||
|
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? ''); if (!sid) return
|
||||||
|
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.type === 'score') {
|
||||||
|
const a = Number(msg.team1 ?? msg.ct)
|
||||||
|
const b = Number(msg.team2 ?? msg.t)
|
||||||
|
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.score) {
|
||||||
|
const a = Number(msg.score.team1 ?? msg.score.ct)
|
||||||
|
const b = Number(msg.score.team2 ?? msg.score.t)
|
||||||
|
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Phase ignorieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectOnce()
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false
|
||||||
|
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||||
|
try { wsRef.current?.close(1000, 'unmounted') } catch {}
|
||||||
|
}
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
// Sichtbarkeit
|
||||||
|
const expiresMs = cfg?.bannerExpiresAt ? new Date(cfg.bannerExpiresAt).getTime() : null
|
||||||
|
const notExpired = expiresMs == null || (Number.isFinite(expiresMs) && expiresMs > Date.now())
|
||||||
|
const meIsParticipant =
|
||||||
|
!!meId && Array.isArray(cfg?.activeParticipants) &&
|
||||||
|
cfg!.activeParticipants!.map(String).includes(meId)
|
||||||
|
|
||||||
|
const canShow = meIsParticipant && notExpired
|
||||||
|
|
||||||
|
// Connected Count + Variant
|
||||||
|
const participants = (cfg?.activeParticipants ?? []).map(String)
|
||||||
|
let connectedCount = 0
|
||||||
|
for (const sid of participants) if (telemetrySet.has(sid)) connectedCount++
|
||||||
|
const isOnline = !!meId && telemetrySet.has(meId)
|
||||||
|
const variant: Variant = isOnline ? 'connected' : 'disconnected'
|
||||||
|
|
||||||
|
const scoreStr = (score.a == null || score.b == null) ? '– : –' : `${score.a} : ${score.b}`
|
||||||
|
|
||||||
|
const envConnect =
|
||||||
|
process.env.NEXT_PUBLIC_STEAM_CONNECT_URI ||
|
||||||
|
process.env.NEXT_PUBLIC_CS2_CONNECT_URI ||
|
||||||
|
'steam://rungameid/730//+retry'
|
||||||
|
const connectUri = connectHref ?? envConnect
|
||||||
|
|
||||||
|
// --- Animation + Höhe an Spacer melden
|
||||||
|
const [rendered, setRendered] = useState<boolean>(canShow)
|
||||||
|
const [anim, setAnim] = useState<'in'|'out'>(canShow ? 'in' : 'out')
|
||||||
const ANIM_MS = 250
|
const ANIM_MS = 250
|
||||||
|
|
||||||
// "rendered": bleibt true, bis die Exit-Animation fertig ist
|
|
||||||
const [rendered, setRendered] = useState<boolean>(targetShow)
|
|
||||||
// anim: 'in' = sichtbar-Animation, 'out' = verstecken-Animation
|
|
||||||
const [anim, setAnim] = useState<'in' | 'out'>(targetShow ? 'in' : 'out')
|
|
||||||
|
|
||||||
// Mount/Unmount steuern anhand targetShow
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetShow) {
|
if (canShow) {
|
||||||
// Mounten + Startzustand unten -> dann hochfahren
|
|
||||||
setRendered(true)
|
setRendered(true)
|
||||||
setAnim('out')
|
setAnim('out')
|
||||||
requestAnimationFrame(() => setAnim('in'))
|
requestAnimationFrame(() => setAnim('in'))
|
||||||
} else {
|
} else {
|
||||||
// Runterfahren -> danach unmounten
|
|
||||||
setAnim('out')
|
setAnim('out')
|
||||||
const t = setTimeout(() => setRendered(false), ANIM_MS)
|
const t = setTimeout(() => setRendered(false), ANIM_MS)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}
|
}
|
||||||
}, [targetShow])
|
}, [canShow])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
@ -116,56 +233,52 @@ export default function GameBanner(props: Props) {
|
|||||||
return () => { ro.disconnect(); setBannerPx(0) }
|
return () => { ro.disconnect(); setBannerPx(0) }
|
||||||
}, [rendered, setBannerPx])
|
}, [rendered, setBannerPx])
|
||||||
|
|
||||||
// ab hier darfst du bedingt rendern
|
|
||||||
if (!rendered) return null
|
if (!rendered) return null
|
||||||
|
|
||||||
const outerBase = inline ? '' : 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
|
// UI Werte
|
||||||
const outerStyle = inline ? undefined : ({ zIndex } as React.CSSProperties)
|
const mapKey = cfg?.activeMapKey ?? undefined
|
||||||
|
const mapLabel = cfg?.activeMapLabel ?? undefined
|
||||||
|
const bgUrl = cfg?.activeMapBg ?? undefined
|
||||||
|
const pretty = {
|
||||||
|
map: mapLabel ?? mapKey ?? '—',
|
||||||
|
score: scoreStr,
|
||||||
|
}
|
||||||
|
|
||||||
const isConnected = variant === 'connected'
|
const outerBase = 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
|
||||||
const wrapperClass = isConnected
|
const outerStyle = { zIndex: isOnline ? 9998 : 9999 } as React.CSSProperties
|
||||||
|
const wrapperClass = variant === 'connected'
|
||||||
? 'bg-emerald-700/95 text-white ring-1 ring-black/10'
|
? 'bg-emerald-700/95 text-white ring-1 ring-black/10'
|
||||||
: 'bg-amber-700/95 text-white ring-1 ring-black/10'
|
: 'bg-amber-700/95 text-white ring-1 ring-black/10'
|
||||||
|
|
||||||
const bgUrl = props.bgUrl ?? pickMapImageFromOptions(mapKey)
|
const iconUrl = variant === 'connected'
|
||||||
const iconUrl = props.iconUrl ?? (isConnected ? (pickMapIcon(mapKey) ?? '') : '/assets/img/icons/ui/disconnect.svg')
|
? (pickMapIcon(mapKey) ?? '')
|
||||||
|
: '/assets/img/icons/ui/disconnect.svg'
|
||||||
|
|
||||||
|
const shownConnected = Math.max(0, Math.min(connectedCount, participants.length))
|
||||||
|
|
||||||
const pretty = {
|
const openGame = () => { try { window.location.href = connectUri } catch {} }
|
||||||
map: mapLabel ?? mapKey ?? '—',
|
|
||||||
phase: phaseStr || 'unknown',
|
|
||||||
score: score ?? '– : –',
|
|
||||||
}
|
|
||||||
|
|
||||||
const openGame = () => {
|
|
||||||
const uri = props.connectUri || 'steam://rungameid/730'
|
|
||||||
try { window.location.href = uri } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfoRow = () => (
|
const InfoRow = () => (
|
||||||
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
|
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
|
||||||
<span>Map: <span className="font-semibold">{pretty.map}</span></span>
|
<span>Map: <span className="font-semibold">{pretty.map}</span></span>
|
||||||
<span>Phase: <span className="font-semibold">{pretty.phase}</span></span>
|
|
||||||
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
|
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
|
||||||
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{shownConnected}</span> / {totalExpected}</span>
|
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{shownConnected}</span> / {participants.length}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const animBase = 'transition-[transform,opacity] duration-250 ease-out will-change-[transform,opacity]'
|
const animBase = 'transition-[transform,opacity] duration-250 ease-out will-change-[transform,opacity]'
|
||||||
const animClass = anim === 'in'
|
const animClass = anim === 'in' ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-full'
|
||||||
? 'opacity-100 translate-y-0'
|
|
||||||
: 'opacity-0 translate-y-full'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={outerBase} style={outerStyle} ref={ref}>
|
<div className={outerBase} style={outerStyle} ref={ref}>
|
||||||
<div className={`relative overflow-hidden shadow-lg ${wrapperClass} ${animBase} ${animClass}`}>
|
<div className={`relative overflow-hidden shadow-lg ${wrapperClass} ${animBase} ${animClass}`}>
|
||||||
{/* Hintergrundbild (Map) */}
|
{/* Hintergrundbild */}
|
||||||
{bgUrl && (
|
{(bgUrl ?? pickMapImageFromOptions(mapKey)) && (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute inset-0"
|
className="pointer-events-none absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${bgUrl})`,
|
backgroundImage: `url(${bgUrl ?? pickMapImageFromOptions(mapKey)})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
@ -191,7 +304,7 @@ export default function GameBanner(props: Props) {
|
|||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={iconUrl}
|
src={iconUrl}
|
||||||
alt={isConnected ? (pretty.map || 'Map') : 'Disconnected'}
|
alt={variant === 'connected' ? (pretty.map || 'Map') : 'Disconnected'}
|
||||||
className="h-6 w-6 object-contain"
|
className="h-6 w-6 object-contain"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@ -204,7 +317,7 @@ export default function GameBanner(props: Props) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm flex items-center gap-2">
|
<div className="text-sm flex items-center gap-2">
|
||||||
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
|
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
|
||||||
{isConnected ? (serverLabel ?? 'CS2 Server') : tGameBanner('not-connected')}
|
{variant === 'connected' ? 'Verbunden' : tGameBanner('not-connected')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<InfoRow />
|
<InfoRow />
|
||||||
@ -235,19 +348,19 @@ export default function GameBanner(props: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{isConnected ? (
|
{variant === 'connected' ? (
|
||||||
<Button color="green" variant="solid" size="md" onClick={openGame} title="Spiel öffnen" />
|
<Button color="green" variant="solid" size="md" onClick={openGame} title="Spiel öffnen" />
|
||||||
) : (
|
) : (
|
||||||
<Button color="green" variant="solid" size="md" onClick={onReconnect} title={tGameBanner('reconnect')} />
|
<Button color="green" variant="solid" size="md" onClick={openGame} title={tGameBanner('reconnect')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isConnected && (
|
{variant === 'connected' && (
|
||||||
<Button
|
<Button
|
||||||
color="transparent"
|
color="transparent"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="md"
|
size="md"
|
||||||
className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none"
|
className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none"
|
||||||
onClick={() => onDisconnect?.()}
|
onClick={() => { try { wsRef.current?.close(1000, 'user requested disconnect') } catch {} }}
|
||||||
aria-label={tGameBanner('quit')}
|
aria-label={tGameBanner('quit')}
|
||||||
title={undefined}
|
title={undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
207
src/app/[locale]/components/GameBannerController.tsx
Normal file
207
src/app/[locale]/components/GameBannerController.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {useEffect, useMemo, useRef, useState} from 'react'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import {useSession} from 'next-auth/react'
|
||||||
|
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
||||||
|
|
||||||
|
type LiveCfg = {
|
||||||
|
activeMatchId: string | null
|
||||||
|
activeMapKey: string | null
|
||||||
|
activeMapLabel: string | null
|
||||||
|
activeMapBg: string | null
|
||||||
|
activeParticipants: string[] | null
|
||||||
|
activeSince: string | null
|
||||||
|
bannerExpiresAt?: string | null
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
|
||||||
|
|
||||||
|
/* ---------- WS helpers ---------- */
|
||||||
|
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
||||||
|
const h = (host ?? '').trim() || '127.0.0.1'
|
||||||
|
const p = (port ?? '').trim() || '8081'
|
||||||
|
const pa = (path ?? '').trim() || '/telemetry'
|
||||||
|
const sch = (scheme ?? '').toLowerCase()
|
||||||
|
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
|
||||||
|
const useWss = sch === 'wss' || (sch !== 'ws' && (p === '443' || pageHttps))
|
||||||
|
const proto = useWss ? 'wss' : 'ws'
|
||||||
|
const portPart = p === '80' || p === '443' ? '' : `:${p}`
|
||||||
|
return `${proto}://${h}${portPart}${pa}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
|
||||||
|
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
|
||||||
|
|
||||||
|
export default function GameBannerController() {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const meId = session?.user?.steamId ? String(session.user.steamId) : null
|
||||||
|
|
||||||
|
const patch = useGameBannerStore(s => s.patchGameBanner)
|
||||||
|
|
||||||
|
// ---- Live-Konfiguration aus DB
|
||||||
|
const { data } = useSWR<{ ok: boolean; data: LiveCfg | null }>(
|
||||||
|
'/api/cs2/server/live',
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
revalidateOnMount: true,
|
||||||
|
revalidateIfStale: true,
|
||||||
|
dedupingInterval: 0,
|
||||||
|
refreshInterval: 30_000,
|
||||||
|
fallbackData: { ok: true, data: null },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const cfg = data?.data ?? null
|
||||||
|
|
||||||
|
// ---- Telemetrie (WS)
|
||||||
|
const url = useMemo(
|
||||||
|
() => makeWsUrl(
|
||||||
|
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_HOST,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_PORT,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_PATH,
|
||||||
|
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_SCHEME
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
|
||||||
|
const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null })
|
||||||
|
const [connectHref, setConnectHref] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// optional: Connect-URI vom Server holen
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/cs2/server', { cache: 'no-store' })
|
||||||
|
if (!r.ok) return
|
||||||
|
const j = await r.json()
|
||||||
|
if (j.connectHref) setConnectHref(j.connectHref)
|
||||||
|
} catch {}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const aliveRef = useRef(true)
|
||||||
|
const retryRef = useRef<number | null>(null)
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
|
||||||
|
// wenn cfg da ist → statische Infos in den Store
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cfg) return
|
||||||
|
patch({
|
||||||
|
mapKey: cfg.activeMapKey ?? undefined,
|
||||||
|
mapLabel: cfg.activeMapLabel ?? undefined,
|
||||||
|
bgUrl: cfg.activeMapBg ?? undefined,
|
||||||
|
totalExpected: Array.isArray(cfg.activeParticipants) ? cfg.activeParticipants.length : 0
|
||||||
|
})
|
||||||
|
}, [cfg, patch])
|
||||||
|
|
||||||
|
// Hilfsfunktion: aus aktuellem State den Store füttern
|
||||||
|
const pushToStore = () => {
|
||||||
|
if (!cfg) return
|
||||||
|
const participants = (cfg.activeParticipants ?? []).map(String)
|
||||||
|
let connectedCount = 0
|
||||||
|
for (const sid of participants) if (telemetrySet.has(sid)) connectedCount++
|
||||||
|
|
||||||
|
const isOnline = !!meId && telemetrySet.has(meId)
|
||||||
|
const scoreStr = (score.a == null || score.b == null) ? '– : –' : `${score.a} : ${score.b}`
|
||||||
|
|
||||||
|
const envConnect =
|
||||||
|
process.env.NEXT_PUBLIC_STEAM_CONNECT_URI ||
|
||||||
|
process.env.NEXT_PUBLIC_CS2_CONNECT_URI ||
|
||||||
|
'steam://rungameid/730//+retry'
|
||||||
|
|
||||||
|
patch({
|
||||||
|
variant: isOnline ? 'connected' : 'disconnected',
|
||||||
|
serverLabel: isOnline ? 'Verbunden' : undefined,
|
||||||
|
connectedCount,
|
||||||
|
totalExpected: participants.length,
|
||||||
|
score: scoreStr,
|
||||||
|
connectUri: connectHref ?? envConnect
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { pushToStore() }, [cfg, telemetrySet, score, meId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
aliveRef.current = true
|
||||||
|
const connectOnce = () => {
|
||||||
|
if (!aliveRef.current || !url) return
|
||||||
|
if (wsRef.current && (
|
||||||
|
wsRef.current.readyState === WebSocket.OPEN ||
|
||||||
|
wsRef.current.readyState === WebSocket.CONNECTING
|
||||||
|
)) return
|
||||||
|
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') console.debug('[GameBannerController] ws open')
|
||||||
|
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') console.debug('[GameBannerController] ws error')
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (process.env.NODE_ENV !== 'production') console.debug('[GameBannerController] ws closed')
|
||||||
|
wsRef.current = null
|
||||||
|
if (aliveRef.current && !retryRef.current) {
|
||||||
|
retryRef.current = window.setTimeout(() => { retryRef.current = null; connectOnce() }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg: any = null
|
||||||
|
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||||
|
const ids = msg.players.map(sidOf).filter(Boolean)
|
||||||
|
setTelemetrySet(toSet(ids))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'player_join' && msg.player) {
|
||||||
|
const sid = sidOf(msg.player)
|
||||||
|
if (!sid) return
|
||||||
|
setTelemetrySet(prev => { const next = new Set(prev); next.add(sid); return next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'player_leave') {
|
||||||
|
const sid = String(msg.steamId ?? msg.steam_id ?? msg.id ?? '')
|
||||||
|
if (!sid) return
|
||||||
|
setTelemetrySet(prev => { const next = new Set(prev); next.delete(sid); return next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'score') {
|
||||||
|
const a = Number(msg.team1 ?? msg.ct)
|
||||||
|
const b = Number(msg.team2 ?? msg.t)
|
||||||
|
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg.score) {
|
||||||
|
const a = Number(msg.score.team1 ?? msg.score.ct)
|
||||||
|
const b = Number(msg.score.team2 ?? msg.score.t)
|
||||||
|
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase wird absichtlich ignoriert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectOnce()
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false
|
||||||
|
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||||
|
try { wsRef.current?.close(1000, 'controller unmounted') } catch {}
|
||||||
|
}
|
||||||
|
}, [url])
|
||||||
|
|
||||||
|
// Headless
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -1,34 +1,81 @@
|
|||||||
// /src/app/[locale]/components/GameBannerHost.tsx
|
// /src/app/[locale]/components/GameBannerHost.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { useSession } from 'next-auth/react'
|
||||||
import GameBanner from './GameBanner'
|
import GameBanner from './GameBanner'
|
||||||
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
||||||
|
|
||||||
|
type LiveCfg = {
|
||||||
|
activeMatchId: string | null
|
||||||
|
activeMapKey: string | null
|
||||||
|
activeMapLabel: string | null
|
||||||
|
activeMapBg: string | null
|
||||||
|
activeParticipants: string[] | null
|
||||||
|
activeSince: string | null
|
||||||
|
bannerExpiresAt?: string | null
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
|
||||||
|
|
||||||
export default function GameBannerHost() {
|
export default function GameBannerHost() {
|
||||||
const banner = useGameBannerStore(s => s.gameBanner)
|
const banner = useGameBannerStore(s => s.gameBanner)
|
||||||
const setBanner = useGameBannerStore(s => s.setGameBanner)
|
const setBanner = useGameBannerStore(s => s.setGameBanner)
|
||||||
const patch = useGameBannerStore(s => s.patchGameBanner)
|
|
||||||
|
|
||||||
if (!banner || !banner.visible) return null
|
const { data: session } = useSession()
|
||||||
|
const meId = session?.user?.steamId ? String(session.user.steamId) : null
|
||||||
|
|
||||||
|
const { data } = useSWR<{ ok: boolean; data: LiveCfg | null }>(
|
||||||
|
'/api/cs2/server/live',
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
revalidateOnMount: true,
|
||||||
|
revalidateIfStale: true,
|
||||||
|
dedupingInterval: 0,
|
||||||
|
refreshInterval: 30000,
|
||||||
|
fallbackData: { ok: true, data: null },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const cfg = data?.data ?? null
|
||||||
|
|
||||||
|
console.debug('[BannerHost] meId=', meId, 'participants=', cfg?.activeParticipants);
|
||||||
|
|
||||||
|
// --- robust notExpired (invalid date-strings => nicht blockieren)
|
||||||
|
const expiresMs = cfg?.bannerExpiresAt ? new Date(cfg.bannerExpiresAt).getTime() : null
|
||||||
|
const notExpired = expiresMs == null || Number.isFinite(expiresMs) && expiresMs > Date.now()
|
||||||
|
|
||||||
|
// --- Teilnehmer-Check (nur das!)
|
||||||
|
const meIsParticipant =
|
||||||
|
!!meId &&
|
||||||
|
Array.isArray(cfg?.activeParticipants) &&
|
||||||
|
cfg.activeParticipants.map(String).includes(meId)
|
||||||
|
|
||||||
|
// nur diese beiden Bedingungen:
|
||||||
|
const canShow = meIsParticipant && notExpired
|
||||||
|
|
||||||
|
if (!canShow) return null
|
||||||
|
|
||||||
|
const connectUriFallback =
|
||||||
|
process.env.NEXT_PUBLIC_STEAM_CONNECT_URI ||
|
||||||
|
process.env.NEXT_PUBLIC_CS2_CONNECT_URI ||
|
||||||
|
'steam://rungameid/730//+retry'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GameBanner
|
<GameBanner
|
||||||
variant={banner.variant ?? 'disconnected'}
|
variant={banner?.variant ?? 'disconnected'}
|
||||||
visible={true}
|
visible={true}
|
||||||
zIndex={banner.zIndex ?? 9999}
|
zIndex={banner?.zIndex ?? 9999}
|
||||||
inline={banner.inline ?? false}
|
inline={banner?.inline ?? false}
|
||||||
serverLabel={banner.serverLabel}
|
serverLabel={banner?.serverLabel}
|
||||||
mapKey={banner.mapKey}
|
mapKey={banner?.mapKey ?? cfg?.activeMapKey ?? undefined}
|
||||||
mapLabel={banner.mapLabel}
|
mapLabel={banner?.mapLabel ?? cfg?.activeMapLabel ?? undefined}
|
||||||
bgUrl={banner.bgUrl}
|
bgUrl={banner?.bgUrl ?? cfg?.activeMapBg ?? undefined}
|
||||||
iconUrl={banner.iconUrl}
|
iconUrl={banner?.iconUrl}
|
||||||
connectUri={banner.connectUri}
|
connectUri={banner?.connectUri ?? connectUriFallback}
|
||||||
phase={banner.phase ?? 'lobby'}
|
score={banner?.score ?? '– : –'}
|
||||||
score={banner.score ?? '– : –'}
|
connectedCount={banner?.connectedCount ?? 0}
|
||||||
connectedCount={banner.connectedCount ?? 0}
|
totalExpected={banner?.totalExpected ?? (cfg?.activeParticipants?.length ?? 0)}
|
||||||
totalExpected={banner.totalExpected ?? 10}
|
missingCount={banner?.missingCount ?? 0}
|
||||||
missingCount={banner.missingCount ?? 0}
|
onReconnect={() => {/* optional: auf connectUri navigieren */}}
|
||||||
onReconnect={() => patch({ variant: 'connected' })}
|
|
||||||
onDisconnect={() => setBanner(null)}
|
onDisconnect={() => setBanner(null)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
// /src/app/[locale]/components/GameBannerSpacer.tsx
|
// /src/app/[locale]/components/GameBannerSpacer.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
||||||
|
|
||||||
|
|||||||
@ -766,30 +766,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
/* ─────────────────── Render ─────────────────── */
|
/* ─────────────────── Render ─────────────────── */
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Topbar: Back + Admin */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link href="/schedule">
|
|
||||||
<Button color="gray" variant="outline">← Zurück</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isAdmin && match.matchType === 'community' && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => setEditMetaOpen(true)}
|
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-white hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Match bearbeiten
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="rounded-md bg-red-600 px-3 py-1.5 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Match löschen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
<div id="match-header" className="relative overflow-hidden rounded-xl ring-1 ring-black/10">
|
<div id="match-header" className="relative overflow-hidden rounded-xl ring-1 ring-black/10">
|
||||||
{/* Map-BG */}
|
{/* Map-BG */}
|
||||||
@ -842,11 +818,26 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 🔼 Topbar: links/rechts normal, Mitte absolut zentriert */}
|
||||||
<div className="relative py-3">
|
<div className="absolute inset-x-3 top-3 z-10">
|
||||||
{/* Meta-Zeile */}
|
<div className="relative flex items-center justify-between min-h-[40px]">
|
||||||
<div className="flex items-center justify-center gap-4">
|
{/* Links: Zurück */}
|
||||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-white/75">
|
<div className="shrink-0">
|
||||||
|
<Link href="/schedule">
|
||||||
|
<Button
|
||||||
|
color="transparent"
|
||||||
|
variant="ghost"
|
||||||
|
className="bg-black/30 text-white ring-1 ring-white/15 hover:bg-black/40"
|
||||||
|
>
|
||||||
|
<span aria-hidden>←</span>
|
||||||
|
<span className="ml-1">Zurück</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mitte: Meta-Chips (immer exakt mittig) */}
|
||||||
|
<div className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
|
<div className="flex items-center justify-center text-center gap-2 flex-wrap text-[11px] sm:text-xs uppercase tracking-wide text-white/75 max-w-[80vw]">
|
||||||
<span className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
|
<span className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
|
||||||
{match.matchType || 'match'}
|
{match.matchType || 'match'}
|
||||||
</span>
|
</span>
|
||||||
@ -865,6 +856,30 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rechts: Admin-Aktionen */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
{isAdmin && match.matchType === 'community' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setEditMetaOpen(true)}
|
||||||
|
className="rounded-md bg-blue-600/90 px-3 py-1.5 text-white ring-1 ring-white/10 hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Match bearbeiten
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="rounded-md bg-red-600/90 px-3 py-1.5 text-white ring-1 ring-white/10 hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Match löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative pt-16 pb-3">
|
||||||
{/* Teams + Score */}
|
{/* Teams + Score */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const isCommunity = match.matchType === 'community'
|
const isCommunity = match.matchType === 'community'
|
||||||
@ -876,7 +891,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
|||||||
: 'truncate text-sm text-white/80'
|
: 'truncate text-sm text-white/80'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:gap-6 px-1">
|
<div className="py-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:gap-6 px-1">
|
||||||
{/* Team A */}
|
{/* Team A */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@ -904,7 +904,7 @@ function TeamMemberViewBody({
|
|||||||
onPointerDownCapture={(e) => { e.stopPropagation(); }} // verhindert Drag/Link schon sehr früh
|
onPointerDownCapture={(e) => { e.stopPropagation(); }} // verhindert Drag/Link schon sehr früh
|
||||||
onMouseDown={(e) => e.stopPropagation()} // fallback
|
onMouseDown={(e) => e.stopPropagation()} // fallback
|
||||||
onClick={(e) => { e.stopPropagation(); setShowPolicyMenu(v => !v) }}
|
onClick={(e) => { e.stopPropagation(); setShowPolicyMenu(v => !v) }}
|
||||||
className="h-[32px] px-2.5 rounded-full text-xs border border-gray-300 dark:border-neutral-600
|
className="h-[32px] px-2.5 rounded-xl text-xs border border-gray-300 dark:border-neutral-600
|
||||||
bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-200
|
bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-200
|
||||||
hover:bg-gray-100 hover:dark:bg-neutral-700 inline-flex items-center gap-1"
|
hover:bg-gray-100 hover:dark:bg-neutral-700 inline-flex items-center gap-1"
|
||||||
title="Beitrittsmodus ändern"
|
title="Beitrittsmodus ändern"
|
||||||
@ -920,7 +920,7 @@ function TeamMemberViewBody({
|
|||||||
)}
|
)}
|
||||||
<span>{joinPolicy === 'INVITE_ONLY' ? 'Nur Einladung' : 'Mit Genehmigung'}</span>
|
<span>{joinPolicy === 'INVITE_ONLY' ? 'Nur Einladung' : 'Mit Genehmigung'}</span>
|
||||||
{savingPolicy && (
|
{savingPolicy && (
|
||||||
<span className="ml-1 inline-block size-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
<span className="ml-1 inline-block size-3 border-2 border-current border-t-transparent rounded-xl animate-spin" />
|
||||||
)}
|
)}
|
||||||
{policySaved && !savingPolicy && <span className="ml-1 text-green-600">✓</span>}
|
{policySaved && !savingPolicy && <span className="ml-1 text-green-600">✓</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -3,14 +3,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
|
import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
|
||||||
import { usePresenceStore } from '@/lib/usePresenceStore'
|
import { usePresenceStore } from '@/lib/usePresenceStore'
|
||||||
import { useTelemetryStore } from '@/lib/useTelemetryStore'
|
import { useTelemetryStore } from '@/lib/useTelemetryStore'
|
||||||
import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
|
import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
|
||||||
import GameBanner from './GameBanner'
|
|
||||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
|
||||||
import { useSSEStore } from '@/lib/useSSEStore'
|
import { useSSEStore } from '@/lib/useSSEStore'
|
||||||
|
|
||||||
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
||||||
@ -28,33 +25,8 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string)
|
|||||||
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
|
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
|
||||||
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
|
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
|
||||||
|
|
||||||
function parseServerLabel(uri: string | null | undefined): string {
|
|
||||||
if (!uri) return 'CS2-Server'
|
|
||||||
const m = uri.match(/steam:\/\/connect\/([^/]+)/i)
|
|
||||||
if (m && m[1]) return m[1].split('/')[0] || 'CS2-Server'
|
|
||||||
try {
|
|
||||||
const u = new URL(uri)
|
|
||||||
return u.host || 'CS2-Server'
|
|
||||||
} catch {
|
|
||||||
return uri ?? 'CS2-Server'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function quoteArg(s: string) {
|
|
||||||
return `"${String(s ?? '').replace(/"/g, '\\"')}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelForMap(key?: string | null): string {
|
|
||||||
if (!key) return '—'
|
|
||||||
const k = String(key).toLowerCase()
|
|
||||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === k)
|
|
||||||
if (opt?.label) return opt.label
|
|
||||||
let s = k.replace(/^(de|cs)_/, '').replace(/_/g, ' ').replace(/(\d)/g, ' $1').replace(/\s+/g, ' ').trim()
|
|
||||||
s = s.split(' ').map(w => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ')
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TelemetrySocket() {
|
export default function TelemetrySocket() {
|
||||||
|
// WS-URL aus ENV ableiten
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
makeWsUrl(
|
makeWsUrl(
|
||||||
@ -66,68 +38,32 @@ export default function TelemetrySocket() {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// aktiver User
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const mySteamId = (session?.user as any)?.steamId ?? null
|
const mySteamId = (session?.user as any)?.steamId ?? null
|
||||||
const myName =
|
|
||||||
(session?.user as any)?.name ??
|
|
||||||
(session?.user as any)?.steamName ??
|
|
||||||
(session?.user as any)?.displayName ??
|
|
||||||
null
|
|
||||||
|
|
||||||
// overlay control
|
// Overlay-Steuerung
|
||||||
const hideOverlay = useReadyOverlayStore((s) => s.hide)
|
const hideOverlay = useReadyOverlayStore((s) => s.hide)
|
||||||
|
|
||||||
// presence/telemetry stores
|
// Presence/Telemetry Stores
|
||||||
const setSnapshot = usePresenceStore((s) => s.setSnapshot)
|
const setSnapshot = usePresenceStore((s) => s.setSnapshot)
|
||||||
const setJoin = usePresenceStore((s) => s.setJoin)
|
const setJoin = usePresenceStore((s) => s.setJoin)
|
||||||
const setLeave = usePresenceStore((s) => s.setLeave)
|
const setLeave = usePresenceStore((s) => s.setLeave)
|
||||||
|
|
||||||
const setMapKey = useTelemetryStore((s) => s.setMapKey)
|
const setMapKey = useTelemetryStore((s) => s.setMapKey)
|
||||||
const phase = useTelemetryStore((s) => s.phase)
|
|
||||||
const setPhase = useTelemetryStore((s) => s.setPhase)
|
const setPhase = useTelemetryStore((s) => s.setPhase)
|
||||||
|
|
||||||
// roster store
|
// 👇 NEU: online-Status für GameBanner
|
||||||
const rosterSet = useMatchRosterStore((s) => s.roster)
|
const setOnline = useTelemetryStore((s) => s.setOnline)
|
||||||
|
|
||||||
|
// Roster-Store
|
||||||
const setRoster = useMatchRosterStore((s) => s.setRoster)
|
const setRoster = useMatchRosterStore((s) => s.setRoster)
|
||||||
const clearRoster = useMatchRosterStore((s) => s.clearRoster)
|
const clearRoster = useMatchRosterStore((s) => s.clearRoster)
|
||||||
|
|
||||||
// local telemetry state
|
// lokaler Telemetry-Set (wer ist per WS online)
|
||||||
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
|
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
|
||||||
const [mapKeyForUi, setMapKeyForUi] = useState<string | null>(null)
|
|
||||||
const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null })
|
|
||||||
|
|
||||||
// connect uri + server name
|
// SSE -> bei relevanten Events Roster nachladen
|
||||||
const [serverId, setServerId] = useState<string | null>(null)
|
|
||||||
const [connectHref, setConnectHref] = useState<string | null>(null)
|
|
||||||
const [serverName, setServerName] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// ws
|
|
||||||
const aliveRef = useRef(true)
|
|
||||||
const retryRef = useRef<number | null>(null)
|
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
|
||||||
|
|
||||||
// dock element (unter dem Main)
|
|
||||||
const [dockEl, setDockEl] = useState<HTMLElement | null>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
setDockEl(document.getElementById('game-banner-dock') as HTMLElement | null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// connect href from API
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/cs2/server', { cache: 'no-store' })
|
|
||||||
if (!r.ok) return
|
|
||||||
const j = await r.json()
|
|
||||||
if (j.connectHref) setConnectHref(j.connectHref)
|
|
||||||
if (j.serverId) setServerId(j.serverId)
|
|
||||||
} catch {}
|
|
||||||
})()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 🔸 Aktuelles Match + Roster laden (kein matchId-Prop nötig)
|
|
||||||
const [currentMatchId, setCurrentMatchId] = useState<string | null>(null)
|
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
async function fetchCurrentRoster() {
|
async function fetchCurrentRoster() {
|
||||||
@ -136,56 +72,60 @@ export default function TelemetrySocket() {
|
|||||||
if (!r.ok) return
|
if (!r.ok) return
|
||||||
const j = await r.json()
|
const j = await r.json()
|
||||||
const ids: string[] = Array.isArray(j?.steamIds) ? j.steamIds : []
|
const ids: string[] = Array.isArray(j?.steamIds) ? j.steamIds : []
|
||||||
setCurrentMatchId(j?.matchId ?? null)
|
|
||||||
if (ids.length) setRoster(ids)
|
if (ids.length) setRoster(ids)
|
||||||
else clearRoster()
|
else clearRoster()
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchCurrentRoster() }, []) // initial
|
// initial + bei Events
|
||||||
// ggf. bei relevanten Events nachziehen
|
useEffect(() => { fetchCurrentRoster() }, [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
const t = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
const t = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
||||||
if (['match-updated','match-ready','map-vote-updated','match-exported'].includes(String(t))) {
|
if (['match-updated', 'match-ready', 'map-vote-updated', 'match-exported'].includes(String(t))) {
|
||||||
fetchCurrentRoster()
|
fetchCurrentRoster()
|
||||||
}
|
}
|
||||||
}, [lastEvent])
|
}, [lastEvent])
|
||||||
|
|
||||||
// websocket connect (wie gehabt)
|
// wenn User ab-/anmeldet → Online-Flag sinnvoll zurücksetzen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mySteamId) setOnline(false)
|
||||||
|
}, [mySteamId, setOnline])
|
||||||
|
|
||||||
|
// WebSocket-Verbindung + Handler
|
||||||
|
const aliveRef = useRef(true)
|
||||||
|
const retryRef = useRef<number | null>(null)
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
aliveRef.current = true
|
aliveRef.current = true
|
||||||
|
|
||||||
let wsLocal: WebSocket | null = null // <- dieses WS schließen wir im Cleanup
|
|
||||||
const connectOnce = () => {
|
const connectOnce = () => {
|
||||||
if (!aliveRef.current || !url) return
|
if (!aliveRef.current || !url) return
|
||||||
|
|
||||||
// ✅ Guard: nicht verbinden, wenn schon OPEN oder CONNECTING
|
// nicht doppelt verbinden
|
||||||
if (wsRef.current && (
|
if (wsRef.current && (
|
||||||
wsRef.current.readyState === WebSocket.OPEN ||
|
wsRef.current.readyState === WebSocket.OPEN ||
|
||||||
wsRef.current.readyState === WebSocket.CONNECTING
|
wsRef.current.readyState === WebSocket.CONNECTING
|
||||||
)) {
|
)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
wsLocal = ws
|
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open')
|
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open')
|
||||||
// ✅ evtl. bestehenden Reconnect-Timer löschen
|
|
||||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] error')
|
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] error')
|
||||||
|
setOnline(false) // konservativ: bei Fehler offline
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] closed')
|
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] closed')
|
||||||
wsRef.current = null
|
wsRef.current = null
|
||||||
// ✅ nur EINEN Reconnect-Timer setzen
|
setOnline(false) // Socket zu → offline
|
||||||
if (aliveRef.current && !retryRef.current) {
|
if (aliveRef.current && !retryRef.current) {
|
||||||
retryRef.current = window.setTimeout(() => {
|
retryRef.current = window.setTimeout(() => {
|
||||||
retryRef.current = null
|
retryRef.current = null
|
||||||
@ -199,20 +139,18 @@ export default function TelemetrySocket() {
|
|||||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
if (msg.type === 'server' && typeof msg.name === 'string' && msg.name.trim()) {
|
// komplette Playerliste
|
||||||
setServerName(msg.name.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||||
setSnapshot(msg.players)
|
setSnapshot(msg.players)
|
||||||
const ids = msg.players.map(sidOf).filter(Boolean)
|
const ids = msg.players.map(sidOf).filter(Boolean)
|
||||||
setTelemetrySet(toSet(ids))
|
setTelemetrySet(toSet(ids))
|
||||||
if (mySteamId) {
|
|
||||||
const present = msg.players.some((p: any) => sidOf(p) === String(mySteamId))
|
const mePresent = !!mySteamId && ids.includes(String(mySteamId))
|
||||||
if (present) hideOverlay()
|
setOnline(!!mePresent)
|
||||||
}
|
if (mePresent) hideOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// join/leave deltas
|
||||||
if (msg.type === 'player_join' && msg.player) {
|
if (msg.type === 'player_join' && msg.player) {
|
||||||
setJoin(msg.player)
|
setJoin(msg.player)
|
||||||
setTelemetrySet(prev => {
|
setTelemetrySet(prev => {
|
||||||
@ -221,8 +159,12 @@ export default function TelemetrySocket() {
|
|||||||
if (sid) next.add(sid)
|
if (sid) next.add(sid)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
const sid = sidOf(msg.player)
|
const sid = sidOf(msg.player)
|
||||||
if (mySteamId && sid && sid === String(mySteamId)) hideOverlay()
|
if (mySteamId && sid === String(mySteamId)) {
|
||||||
|
setOnline(true)
|
||||||
|
hideOverlay()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'player_leave') {
|
if (msg.type === 'player_leave') {
|
||||||
@ -233,33 +175,20 @@ export default function TelemetrySocket() {
|
|||||||
if (sid) next.delete(sid)
|
if (sid) next.delete(sid)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (mySteamId && sid === String(mySteamId)) {
|
||||||
|
setOnline(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// map nur für UI (Phase separat)
|
// Map-Key und Phase ins Telemetry-Store schreiben
|
||||||
if (msg.type === 'map' && typeof msg.name === 'string') {
|
if (msg.type === 'map' && typeof msg.name === 'string') {
|
||||||
const key = msg.name.toLowerCase()
|
const key = msg.name.toLowerCase()
|
||||||
setMapKey(key)
|
setMapKey(key)
|
||||||
setMapKeyForUi(key)
|
|
||||||
if (typeof msg.serverName === 'string' && msg.serverName.trim()) {
|
|
||||||
setServerName(msg.serverName.trim())
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Phase ausschließlich aus WS
|
|
||||||
if (msg.type === 'phase' && typeof msg.phase === 'string') {
|
if (msg.type === 'phase' && typeof msg.phase === 'string') {
|
||||||
setPhase(String(msg.phase).toLowerCase() as any)
|
setPhase(String(msg.phase).toLowerCase() as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score
|
|
||||||
if (msg.type === 'score') {
|
|
||||||
const a = Number(msg.team1 ?? msg.ct)
|
|
||||||
const b = Number(msg.team2 ?? msg.t)
|
|
||||||
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
|
||||||
} else if (msg.score) {
|
|
||||||
const a = Number(msg.score.team1 ?? msg.score.ct)
|
|
||||||
const b = Number(msg.score.team2 ?? msg.score.t)
|
|
||||||
setScore({ a: Number.isFinite(a) ? a : null, b: Number.isFinite(b) ? b : null })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,86 +197,10 @@ export default function TelemetrySocket() {
|
|||||||
aliveRef.current = false
|
aliveRef.current = false
|
||||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||||
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
|
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
|
||||||
|
setOnline(false)
|
||||||
}
|
}
|
||||||
}, [url])
|
}, [url, hideOverlay, mySteamId, setJoin, setLeave, setMapKey, setPhase, setSnapshot, setOnline])
|
||||||
|
|
||||||
|
// ⬇️ WICHTIG: Kein Banner-Rendering mehr hier. UI kommt aus GameBannerHost.
|
||||||
// Wenn die API { matchId: null } liefert → KEIN Banner
|
return null
|
||||||
if (!currentMatchId) return null
|
|
||||||
|
|
||||||
// ----- banner logic
|
|
||||||
const myId = mySteamId ? String(mySteamId) : null
|
|
||||||
const roster =
|
|
||||||
rosterSet instanceof Set && rosterSet.size > 0
|
|
||||||
? rosterSet
|
|
||||||
: (myId ? new Set<string>([myId]) : new Set<string>())
|
|
||||||
|
|
||||||
const iAmExpected = !!myId && roster.has(myId)
|
|
||||||
const iAmOnline = !!myId && telemetrySet.has(myId)
|
|
||||||
|
|
||||||
let intersectCount = 0
|
|
||||||
for (const sid of roster) if (telemetrySet.has(sid)) intersectCount++
|
|
||||||
const totalExpected = roster.size
|
|
||||||
|
|
||||||
const connectUri =
|
|
||||||
connectHref ||
|
|
||||||
process.env.NEXT_PUBLIC_STEAM_CONNECT_URI ||
|
|
||||||
process.env.NEXT_PUBLIC_CS2_CONNECT_URI ||
|
|
||||||
'steam://rungameid/730//+retry'
|
|
||||||
|
|
||||||
const effectiveServerLabel = (serverName && serverName.trim()) || parseServerLabel(connectUri)
|
|
||||||
const prettyPhase = phase ?? 'unknown'
|
|
||||||
const prettyScore = (score.a == null || score.b == null) ? '– : –' : `${score.a} : ${score.b}`
|
|
||||||
const prettyMapLabel = labelForMap(mapKeyForUi)
|
|
||||||
|
|
||||||
const handleReconnect = () => {
|
|
||||||
try { window.location.href = connectUri } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
|
||||||
aliveRef.current = false
|
|
||||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
|
||||||
try { wsRef.current?.close(1000, 'user requested disconnect') } catch {}
|
|
||||||
wsRef.current = null
|
|
||||||
setTelemetrySet(new Set())
|
|
||||||
|
|
||||||
try {
|
|
||||||
const who = myName || mySteamId
|
|
||||||
if (who) {
|
|
||||||
const cmd = `kick ${quoteArg(String(who))}`
|
|
||||||
await fetch('/api/cs2/server/send-command', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
body: JSON.stringify({ command: cmd, serverId: serverId ?? undefined }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const variant: 'connected' | 'disconnected' = iAmOnline ? 'connected' : 'disconnected'
|
|
||||||
const visible = iAmExpected && !!currentMatchId
|
|
||||||
const zIndex = iAmOnline ? 9998 : 9999
|
|
||||||
|
|
||||||
const bannerEl = (
|
|
||||||
<GameBanner
|
|
||||||
variant={variant}
|
|
||||||
visible={visible}
|
|
||||||
zIndex={zIndex}
|
|
||||||
inline={!!dockEl}
|
|
||||||
connectedCount={intersectCount}
|
|
||||||
totalExpected={totalExpected}
|
|
||||||
connectUri={connectUri}
|
|
||||||
onReconnect={handleReconnect}
|
|
||||||
onDisconnect={handleDisconnect}
|
|
||||||
serverLabel={effectiveServerLabel}
|
|
||||||
mapKey={mapKeyForUi ?? undefined}
|
|
||||||
mapLabel={prettyMapLabel}
|
|
||||||
phase={prettyPhase}
|
|
||||||
score={prettyScore}
|
|
||||||
missingCount={totalExpected - intersectCount}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return dockEl ? createPortal(bannerEl, dockEl) : bannerEl
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -328,10 +328,10 @@ export default function TeamSidebar({
|
|||||||
src={primIcon}
|
src={primIcon}
|
||||||
alt={prim?.name ?? 'primary'}
|
alt={prim?.name ?? 'primary'}
|
||||||
title={prim?.name ?? 'primary'}
|
title={prim?.name ?? 'primary'}
|
||||||
className={`h-16 w-16 transition filter ${
|
className={`h-16 w-16 transition filter p-2 rounded-md ${
|
||||||
primActive
|
primActive
|
||||||
? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2'
|
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30 '
|
||||||
: 'grayscale brightness-90 contrast-75 opacity-90'
|
: 'grayscale brightness-90 contrast-75 opacity-90 '
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -351,8 +351,8 @@ export default function TeamSidebar({
|
|||||||
src={secIcon}
|
src={secIcon}
|
||||||
alt={sec?.name ?? 'secondary'}
|
alt={sec?.name ?? 'secondary'}
|
||||||
title={sec?.name ?? 'secondary'}
|
title={sec?.name ?? 'secondary'}
|
||||||
className={`h-10 w-10 transition filter ${
|
className={`h-10 w-10 transition filter p-2 rounded-md ${
|
||||||
secActive ? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2' : 'grayscale brightness-90 contrast-75 opacity-90'
|
secActive ? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30' : 'grayscale brightness-90 contrast-75 opacity-90'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -18,8 +18,8 @@ import UserActivityTracker from './components/UserActivityTracker';
|
|||||||
import AudioPrimer from './components/AudioPrimer';
|
import AudioPrimer from './components/AudioPrimer';
|
||||||
import ReadyOverlayHost from './components/ReadyOverlayHost';
|
import ReadyOverlayHost from './components/ReadyOverlayHost';
|
||||||
import TelemetrySocket from './components/TelemetrySocket';
|
import TelemetrySocket from './components/TelemetrySocket';
|
||||||
import GameBannerSpacer from './components/GameBannerSpacer';
|
import GameBannerSpacer from './components/GameBannerSpacer'
|
||||||
import GameBannerHost from './components/GameBannerHost';
|
import GameBanner from './components/GameBanner';
|
||||||
|
|
||||||
const geistSans = Geist({variable: '--font-geist-sans', subsets: ['latin']});
|
const geistSans = Geist({variable: '--font-geist-sans', subsets: ['latin']});
|
||||||
const geistMono = Geist_Mono({variable: '--font-geist-mono', subsets: ['latin']});
|
const geistMono = Geist_Mono({variable: '--font-geist-mono', subsets: ['latin']});
|
||||||
@ -66,7 +66,7 @@ export default async function RootLayout({children, params}: Props) {
|
|||||||
<main className="flex-1 min-w-0 min-h-0 overflow-auto">
|
<main className="flex-1 min-w-0 min-h-0 overflow-auto">
|
||||||
<div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div>
|
<div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
<GameBannerHost />
|
<GameBanner /> {/* ⬅️ nur noch diese */}
|
||||||
<GameBannerSpacer className="hidden sm:block" />
|
<GameBannerSpacer className="hidden sm:block" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /src/app/api/cs2/server/live/route.ts
|
||||||
|
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,23 @@
|
|||||||
// /src/app/lib/useTelemetryStore.ts
|
// /src/app/lib/useTelemetryStore.ts
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
type Phase = 'unknown'|'warmup'|'freezetime'|'live'|'bomb'|'over'
|
||||||
|
|
||||||
type TelemetryState = {
|
type TelemetryState = {
|
||||||
mapKey: string | null
|
mapKey: string | null
|
||||||
setMapKey: (k: string|null) => void
|
setMapKey: (k: string|null) => void
|
||||||
|
|
||||||
phase: 'unknown'|'warmup'|'freezetime'|'live'|'bomb'|'over'
|
phase: Phase
|
||||||
setPhase: (p: TelemetryState['phase']) => void
|
setPhase: (p: Phase) => void
|
||||||
|
|
||||||
|
// wer laut Planning/Roster erwartet wird
|
||||||
rosterSteamIds: Set<string>
|
rosterSteamIds: Set<string>
|
||||||
setRosterSteamIds: (ids: string[]) => void
|
setRosterSteamIds: (ids: string[]) => void
|
||||||
clearRoster: () => void
|
clearRoster: () => void
|
||||||
|
|
||||||
|
// 👇 NEU: komme ich in der WS-"players"-Liste vor?
|
||||||
|
isOnline: boolean
|
||||||
|
setOnline: (v: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTelemetryStore = create<TelemetryState>((set) => ({
|
export const useTelemetryStore = create<TelemetryState>((set) => ({
|
||||||
@ -23,4 +30,8 @@ export const useTelemetryStore = create<TelemetryState>((set) => ({
|
|||||||
rosterSteamIds: new Set<string>(),
|
rosterSteamIds: new Set<string>(),
|
||||||
setRosterSteamIds: (ids) => set({ rosterSteamIds: new Set(ids ?? []) }),
|
setRosterSteamIds: (ids) => set({ rosterSteamIds: new Set(ids ?? []) }),
|
||||||
clearRoster: () => set({ rosterSteamIds: new Set() }),
|
clearRoster: () => set({ rosterSteamIds: new Set() }),
|
||||||
|
|
||||||
|
// 👇 NEU
|
||||||
|
isOnline: false,
|
||||||
|
setOnline: (v) => set({ isOnline: v }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user