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-dom": "^19.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"swr": "^2.3.6",
|
||||
"undici": "^7.15.0",
|
||||
"vanilla-calendar-pro": "^3.0.4",
|
||||
"zustand": "^5.0.3"
|
||||
@ -3534,6 +3535,15 @@
|
||||
"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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
@ -7628,6 +7638,19 @@
|
||||
"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": {
|
||||
"version": "6.2.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@ -50,6 +50,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"swr": "^2.3.6",
|
||||
"undici": "^7.15.0",
|
||||
"vanilla-calendar-pro": "^3.0.4",
|
||||
"zustand": "^5.0.3"
|
||||
|
||||
@ -1,110 +1,227 @@
|
||||
// src/app/[locale]/components/GameBanner.tsx
|
||||
// /src/app/[locale]/components/GameBanner.tsx
|
||||
'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 Button from './Button'
|
||||
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
||||
import {MAP_OPTIONS} from '@/lib/mapOptions'
|
||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
export type GameBannerVariant = 'connected' | 'disconnected'
|
||||
|
||||
type Props = {
|
||||
variant: GameBannerVariant
|
||||
visible: boolean
|
||||
zIndex?: number
|
||||
connectedCount: number
|
||||
totalExpected: number
|
||||
onReconnect: () => void
|
||||
onDisconnect?: () => void
|
||||
serverLabel?: string
|
||||
mapKey?: string
|
||||
mapLabel?: string
|
||||
phase?: string
|
||||
score?: string
|
||||
inline?: boolean
|
||||
connectUri?: string
|
||||
missingCount?: number
|
||||
bgUrl?: string
|
||||
iconUrl?: string
|
||||
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
|
||||
}
|
||||
|
||||
type Variant = 'connected' | 'disconnected'
|
||||
|
||||
const fetcher = (url: string) => fetch(url, { cache: 'no-store' }).then(r => r.json())
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
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
|
||||
}
|
||||
|
||||
function pickMapImageFromOptions(mapKey?: string): string | null {
|
||||
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 }
|
||||
const pickMapImageFromOptions = (mapKey?: string) => {
|
||||
if (!mapKey) return null
|
||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
||||
if (!opt || !opt.images?.length) return null
|
||||
const idx = Math.abs(hashStr(mapKey)) % opt.images.length
|
||||
return opt.images[idx] ?? null
|
||||
}
|
||||
|
||||
function pickMapIcon(mapKey?: string): string | null {
|
||||
const pickMapIcon = (mapKey?: string) => {
|
||||
if (!mapKey) return null
|
||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === mapKey.toLowerCase())
|
||||
return opt?.icon ?? null
|
||||
}
|
||||
|
||||
// Banner auf mobilen Bildschirmen gar nicht rendern (unter sm)
|
||||
function useIsSmDown() {
|
||||
const [smDown, setSmDown] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 639.98px)')
|
||||
setSmDown(mq.matches)
|
||||
const onChange = (e: MediaQueryListEvent) => setSmDown(e.matches)
|
||||
mq.addEventListener('change', onChange)
|
||||
return () => mq.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
return smDown
|
||||
const sidOf = (p: any) => String(p?.steamId ?? p?.steam_id ?? p?.id ?? '')
|
||||
const toSet = (arr: Iterable<string>) => new Set(Array.from(arr).map(String))
|
||||
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}`
|
||||
}
|
||||
|
||||
/* ---------- component ---------- */
|
||||
export default function GameBanner(props: Props) {
|
||||
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()
|
||||
export default function GameBanner() {
|
||||
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
|
||||
const targetShow = !isSmDown && visible && phaseStr !== 'unknown'
|
||||
// WS url
|
||||
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
|
||||
|
||||
// "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(() => {
|
||||
if (targetShow) {
|
||||
// Mounten + Startzustand unten -> dann hochfahren
|
||||
if (canShow) {
|
||||
setRendered(true)
|
||||
setAnim('out')
|
||||
requestAnimationFrame(() => setAnim('in'))
|
||||
} else {
|
||||
// Runterfahren -> danach unmounten
|
||||
setAnim('out')
|
||||
const t = setTimeout(() => setRendered(false), ANIM_MS)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [targetShow])
|
||||
}, [canShow])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
@ -116,56 +233,52 @@ export default function GameBanner(props: Props) {
|
||||
return () => { ro.disconnect(); setBannerPx(0) }
|
||||
}, [rendered, setBannerPx])
|
||||
|
||||
// ab hier darfst du bedingt rendern
|
||||
if (!rendered) return null
|
||||
|
||||
const outerBase = inline ? '' : 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
|
||||
const outerStyle = inline ? undefined : ({ zIndex } as React.CSSProperties)
|
||||
// UI Werte
|
||||
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 wrapperClass = isConnected
|
||||
const outerBase = 'fixed right-0 bottom-0 left-0 sm:left-[16rem]'
|
||||
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-amber-700/95 text-white ring-1 ring-black/10'
|
||||
|
||||
const bgUrl = props.bgUrl ?? pickMapImageFromOptions(mapKey)
|
||||
const iconUrl = props.iconUrl ?? (isConnected ? (pickMapIcon(mapKey) ?? '') : '/assets/img/icons/ui/disconnect.svg')
|
||||
const iconUrl = variant === 'connected'
|
||||
? (pickMapIcon(mapKey) ?? '')
|
||||
: '/assets/img/icons/ui/disconnect.svg'
|
||||
|
||||
const shownConnected = Math.max(0, Math.min(connectedCount, participants.length))
|
||||
|
||||
const pretty = {
|
||||
map: mapLabel ?? mapKey ?? '—',
|
||||
phase: phaseStr || 'unknown',
|
||||
score: score ?? '– : –',
|
||||
}
|
||||
|
||||
const openGame = () => {
|
||||
const uri = props.connectUri || 'steam://rungameid/730'
|
||||
try { window.location.href = uri } catch {}
|
||||
}
|
||||
const openGame = () => { try { window.location.href = connectUri } catch {} }
|
||||
|
||||
const InfoRow = () => (
|
||||
<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>Phase: <span className="font-semibold">{pretty.phase}</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>
|
||||
)
|
||||
|
||||
const animBase = 'transition-[transform,opacity] duration-250 ease-out will-change-[transform,opacity]'
|
||||
const animClass = anim === 'in'
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 translate-y-full'
|
||||
const animClass = anim === 'in' ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-full'
|
||||
|
||||
return (
|
||||
<div className={outerBase} style={outerStyle} ref={ref}>
|
||||
<div className={`relative overflow-hidden shadow-lg ${wrapperClass} ${animBase} ${animClass}`}>
|
||||
{/* Hintergrundbild (Map) */}
|
||||
{bgUrl && (
|
||||
{/* Hintergrundbild */}
|
||||
{(bgUrl ?? pickMapImageFromOptions(mapKey)) && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url(${bgUrl})`,
|
||||
backgroundImage: `url(${bgUrl ?? pickMapImageFromOptions(mapKey)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
opacity: 0.5,
|
||||
@ -191,7 +304,7 @@ export default function GameBanner(props: Props) {
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt={isConnected ? (pretty.map || 'Map') : 'Disconnected'}
|
||||
alt={variant === 'connected' ? (pretty.map || 'Map') : 'Disconnected'}
|
||||
className="h-6 w-6 object-contain"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
@ -204,7 +317,7 @@ export default function GameBanner(props: Props) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<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">
|
||||
{isConnected ? (serverLabel ?? 'CS2 Server') : tGameBanner('not-connected')}
|
||||
{variant === 'connected' ? 'Verbunden' : tGameBanner('not-connected')}
|
||||
</span>
|
||||
</div>
|
||||
<InfoRow />
|
||||
@ -235,19 +348,19 @@ export default function GameBanner(props: Props) {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isConnected ? (
|
||||
{variant === 'connected' ? (
|
||||
<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
|
||||
color="transparent"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
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')}
|
||||
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
|
||||
|
||||
'use client'
|
||||
import useSWR from 'swr'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import GameBanner from './GameBanner'
|
||||
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
||||
|
||||
export default function GameBannerHost() {
|
||||
const banner = useGameBannerStore(s => s.gameBanner)
|
||||
const setBanner = useGameBannerStore(s => s.setGameBanner)
|
||||
const patch = useGameBannerStore(s => s.patchGameBanner)
|
||||
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())
|
||||
|
||||
if (!banner || !banner.visible) return null
|
||||
export default function GameBannerHost() {
|
||||
const banner = useGameBannerStore(s => s.gameBanner)
|
||||
const setBanner = useGameBannerStore(s => s.setGameBanner)
|
||||
|
||||
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 (
|
||||
<GameBanner
|
||||
variant={banner.variant ?? 'disconnected'}
|
||||
variant={banner?.variant ?? 'disconnected'}
|
||||
visible={true}
|
||||
zIndex={banner.zIndex ?? 9999}
|
||||
inline={banner.inline ?? false}
|
||||
serverLabel={banner.serverLabel}
|
||||
mapKey={banner.mapKey}
|
||||
mapLabel={banner.mapLabel}
|
||||
bgUrl={banner.bgUrl}
|
||||
iconUrl={banner.iconUrl}
|
||||
connectUri={banner.connectUri}
|
||||
phase={banner.phase ?? 'lobby'}
|
||||
score={banner.score ?? '– : –'}
|
||||
connectedCount={banner.connectedCount ?? 0}
|
||||
totalExpected={banner.totalExpected ?? 10}
|
||||
missingCount={banner.missingCount ?? 0}
|
||||
onReconnect={() => patch({ variant: 'connected' })}
|
||||
zIndex={banner?.zIndex ?? 9999}
|
||||
inline={banner?.inline ?? false}
|
||||
serverLabel={banner?.serverLabel}
|
||||
mapKey={banner?.mapKey ?? cfg?.activeMapKey ?? undefined}
|
||||
mapLabel={banner?.mapLabel ?? cfg?.activeMapLabel ?? undefined}
|
||||
bgUrl={banner?.bgUrl ?? cfg?.activeMapBg ?? undefined}
|
||||
iconUrl={banner?.iconUrl}
|
||||
connectUri={banner?.connectUri ?? connectUriFallback}
|
||||
score={banner?.score ?? '– : –'}
|
||||
connectedCount={banner?.connectedCount ?? 0}
|
||||
totalExpected={banner?.totalExpected ?? (cfg?.activeParticipants?.length ?? 0)}
|
||||
missingCount={banner?.missingCount ?? 0}
|
||||
onReconnect={() => {/* optional: auf connectUri navigieren */}}
|
||||
onDisconnect={() => setBanner(null)}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// /src/app/[locale]/components/GameBannerSpacer.tsx
|
||||
|
||||
'use client'
|
||||
import { useGameBannerStore } from '@/lib/useGameBannerStore'
|
||||
|
||||
|
||||
@ -766,30 +766,6 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
/* ─────────────────── Render ─────────────────── */
|
||||
return (
|
||||
<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 */}
|
||||
<div id="match-header" className="relative overflow-hidden rounded-xl ring-1 ring-black/10">
|
||||
{/* Map-BG */}
|
||||
@ -842,29 +818,68 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative py-3">
|
||||
{/* Meta-Zeile */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-white/75">
|
||||
<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'}
|
||||
</span>
|
||||
{dateString && (
|
||||
<>
|
||||
<span className="opacity-60">•</span>
|
||||
<time className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
|
||||
{readableDate}
|
||||
</time>
|
||||
</>
|
||||
{/* 🔼 Topbar: links/rechts normal, Mitte absolut zentriert */}
|
||||
<div className="absolute inset-x-3 top-3 z-10">
|
||||
<div className="relative flex items-center justify-between min-h-[40px]">
|
||||
{/* Links: Zurück */}
|
||||
<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">
|
||||
{match.matchType || 'match'}
|
||||
</span>
|
||||
{dateString && (
|
||||
<>
|
||||
<span className="opacity-60">•</span>
|
||||
<time className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
|
||||
{readableDate}
|
||||
</time>
|
||||
</>
|
||||
)}
|
||||
<span className="opacity-60">•</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
|
||||
Best of {bestOf}
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
<span className="opacity-60">•</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-black/25 px-2 py-1 ring-1 ring-white/10">
|
||||
Best of {bestOf}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative pt-16 pb-3">
|
||||
{/* Teams + Score */}
|
||||
{(() => {
|
||||
const isCommunity = match.matchType === 'community'
|
||||
@ -876,7 +891,7 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu
|
||||
: 'truncate text-sm text-white/80'
|
||||
|
||||
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 */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@ -904,7 +904,7 @@ function TeamMemberViewBody({
|
||||
onPointerDownCapture={(e) => { e.stopPropagation(); }} // verhindert Drag/Link schon sehr früh
|
||||
onMouseDown={(e) => e.stopPropagation()} // fallback
|
||||
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
|
||||
hover:bg-gray-100 hover:dark:bg-neutral-700 inline-flex items-center gap-1"
|
||||
title="Beitrittsmodus ändern"
|
||||
@ -920,7 +920,7 @@ function TeamMemberViewBody({
|
||||
)}
|
||||
<span>{joinPolicy === 'INVITE_ONLY' ? 'Nur Einladung' : 'Mit Genehmigung'}</span>
|
||||
{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>}
|
||||
</button>
|
||||
|
||||
@ -3,14 +3,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
|
||||
import { usePresenceStore } from '@/lib/usePresenceStore'
|
||||
import { useTelemetryStore } from '@/lib/useTelemetryStore'
|
||||
import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
|
||||
import GameBanner from './GameBanner'
|
||||
import { MAP_OPTIONS } from '@/lib/mapOptions'
|
||||
import { useSSEStore } from '@/lib/useSSEStore'
|
||||
|
||||
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 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() {
|
||||
// WS-URL aus ENV ableiten
|
||||
const url = useMemo(
|
||||
() =>
|
||||
makeWsUrl(
|
||||
@ -66,68 +38,32 @@ export default function TelemetrySocket() {
|
||||
[]
|
||||
)
|
||||
|
||||
// aktiver User
|
||||
const { data: session } = useSession()
|
||||
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)
|
||||
|
||||
// presence/telemetry stores
|
||||
// Presence/Telemetry Stores
|
||||
const setSnapshot = usePresenceStore((s) => s.setSnapshot)
|
||||
const setJoin = usePresenceStore((s) => s.setJoin)
|
||||
const setLeave = usePresenceStore((s) => s.setLeave)
|
||||
|
||||
const setMapKey = useTelemetryStore((s) => s.setMapKey)
|
||||
const phase = useTelemetryStore((s) => s.phase)
|
||||
const setPhase = useTelemetryStore((s) => s.setPhase)
|
||||
|
||||
// roster store
|
||||
const rosterSet = useMatchRosterStore((s) => s.roster)
|
||||
// 👇 NEU: online-Status für GameBanner
|
||||
const setOnline = useTelemetryStore((s) => s.setOnline)
|
||||
|
||||
// Roster-Store
|
||||
const setRoster = useMatchRosterStore((s) => s.setRoster)
|
||||
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 [mapKeyForUi, setMapKeyForUi] = useState<string | null>(null)
|
||||
const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null })
|
||||
|
||||
// connect uri + server name
|
||||
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)
|
||||
// SSE -> bei relevanten Events Roster nachladen
|
||||
const { lastEvent } = useSSEStore()
|
||||
|
||||
async function fetchCurrentRoster() {
|
||||
@ -136,56 +72,60 @@ export default function TelemetrySocket() {
|
||||
if (!r.ok) return
|
||||
const j = await r.json()
|
||||
const ids: string[] = Array.isArray(j?.steamIds) ? j.steamIds : []
|
||||
setCurrentMatchId(j?.matchId ?? null)
|
||||
if (ids.length) setRoster(ids)
|
||||
else clearRoster()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCurrentRoster() }, []) // initial
|
||||
// ggf. bei relevanten Events nachziehen
|
||||
// initial + bei Events
|
||||
useEffect(() => { fetchCurrentRoster() }, [])
|
||||
useEffect(() => {
|
||||
if (!lastEvent) return
|
||||
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()
|
||||
}
|
||||
}, [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(() => {
|
||||
aliveRef.current = true
|
||||
|
||||
let wsLocal: WebSocket | null = null // <- dieses WS schließen wir im Cleanup
|
||||
const connectOnce = () => {
|
||||
if (!aliveRef.current || !url) return
|
||||
|
||||
// ✅ Guard: nicht verbinden, wenn schon OPEN oder CONNECTING
|
||||
// nicht doppelt verbinden
|
||||
if (wsRef.current && (
|
||||
wsRef.current.readyState === WebSocket.OPEN ||
|
||||
wsRef.current.readyState === WebSocket.CONNECTING
|
||||
)) {
|
||||
return
|
||||
}
|
||||
)) return
|
||||
|
||||
const ws = new WebSocket(url)
|
||||
wsLocal = ws
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
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 }
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] error')
|
||||
setOnline(false) // konservativ: bei Fehler offline
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] closed')
|
||||
wsRef.current = null
|
||||
// ✅ nur EINEN Reconnect-Timer setzen
|
||||
setOnline(false) // Socket zu → offline
|
||||
if (aliveRef.current && !retryRef.current) {
|
||||
retryRef.current = window.setTimeout(() => {
|
||||
retryRef.current = null
|
||||
@ -199,20 +139,18 @@ export default function TelemetrySocket() {
|
||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
||||
if (!msg) return
|
||||
|
||||
if (msg.type === 'server' && typeof msg.name === 'string' && msg.name.trim()) {
|
||||
setServerName(msg.name.trim())
|
||||
}
|
||||
|
||||
// komplette Playerliste
|
||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||
setSnapshot(msg.players)
|
||||
const ids = msg.players.map(sidOf).filter(Boolean)
|
||||
setTelemetrySet(toSet(ids))
|
||||
if (mySteamId) {
|
||||
const present = msg.players.some((p: any) => sidOf(p) === String(mySteamId))
|
||||
if (present) hideOverlay()
|
||||
}
|
||||
|
||||
const mePresent = !!mySteamId && ids.includes(String(mySteamId))
|
||||
setOnline(!!mePresent)
|
||||
if (mePresent) hideOverlay()
|
||||
}
|
||||
|
||||
// join/leave deltas
|
||||
if (msg.type === 'player_join' && msg.player) {
|
||||
setJoin(msg.player)
|
||||
setTelemetrySet(prev => {
|
||||
@ -221,8 +159,12 @@ export default function TelemetrySocket() {
|
||||
if (sid) next.add(sid)
|
||||
return next
|
||||
})
|
||||
|
||||
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') {
|
||||
@ -233,33 +175,20 @@ export default function TelemetrySocket() {
|
||||
if (sid) next.delete(sid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// map nur für UI (Phase separat)
|
||||
if (msg.type === 'map' && typeof msg.name === 'string') {
|
||||
const key = msg.name.toLowerCase()
|
||||
setMapKey(key)
|
||||
setMapKeyForUi(key)
|
||||
if (typeof msg.serverName === 'string' && msg.serverName.trim()) {
|
||||
setServerName(msg.serverName.trim())
|
||||
if (mySteamId && sid === String(mySteamId)) {
|
||||
setOnline(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase ausschließlich aus WS
|
||||
// Map-Key und Phase ins Telemetry-Store schreiben
|
||||
if (msg.type === 'map' && typeof msg.name === 'string') {
|
||||
const key = msg.name.toLowerCase()
|
||||
setMapKey(key)
|
||||
}
|
||||
if (msg.type === 'phase' && typeof msg.phase === 'string') {
|
||||
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
|
||||
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
|
||||
setOnline(false)
|
||||
}
|
||||
}, [url])
|
||||
}, [url, hideOverlay, mySteamId, setJoin, setLeave, setMapKey, setPhase, setSnapshot, setOnline])
|
||||
|
||||
|
||||
// Wenn die API { matchId: null } liefert → KEIN Banner
|
||||
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
|
||||
// ⬇️ WICHTIG: Kein Banner-Rendering mehr hier. UI kommt aus GameBannerHost.
|
||||
return null
|
||||
}
|
||||
|
||||
@ -328,10 +328,10 @@ export default function TeamSidebar({
|
||||
src={primIcon}
|
||||
alt={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
|
||||
? 'grayscale-0 opacity-100 rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2'
|
||||
: 'grayscale brightness-90 contrast-75 opacity-90'
|
||||
? 'grayscale-0 opacity-100 border border-neutral-700/60 bg-neutral-900/30 '
|
||||
: 'grayscale brightness-90 contrast-75 opacity-90 '
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
@ -351,8 +351,8 @@ export default function TeamSidebar({
|
||||
src={secIcon}
|
||||
alt={sec?.name ?? 'secondary'}
|
||||
title={sec?.name ?? 'secondary'}
|
||||
className={`h-10 w-10 transition filter ${
|
||||
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'
|
||||
className={`h-10 w-10 transition filter p-2 rounded-md ${
|
||||
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 ReadyOverlayHost from './components/ReadyOverlayHost';
|
||||
import TelemetrySocket from './components/TelemetrySocket';
|
||||
import GameBannerSpacer from './components/GameBannerSpacer';
|
||||
import GameBannerHost from './components/GameBannerHost';
|
||||
import GameBannerSpacer from './components/GameBannerSpacer'
|
||||
import GameBanner from './components/GameBanner';
|
||||
|
||||
const geistSans = Geist({variable: '--font-geist-sans', 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">
|
||||
<div className="h-full min-h-0 box-border p-4 sm:p-6">{children}</div>
|
||||
</main>
|
||||
<GameBannerHost />
|
||||
<GameBanner /> {/* ⬅️ nur noch diese */}
|
||||
<GameBannerSpacer className="hidden sm:block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/api/cs2/server/live/route.ts
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
// /src/app/lib/useTelemetryStore.ts
|
||||
import { create } from 'zustand'
|
||||
|
||||
type Phase = 'unknown'|'warmup'|'freezetime'|'live'|'bomb'|'over'
|
||||
|
||||
type TelemetryState = {
|
||||
mapKey: string | null
|
||||
setMapKey: (k: string|null) => void
|
||||
|
||||
phase: 'unknown'|'warmup'|'freezetime'|'live'|'bomb'|'over'
|
||||
setPhase: (p: TelemetryState['phase']) => void
|
||||
phase: Phase
|
||||
setPhase: (p: Phase) => void
|
||||
|
||||
// wer laut Planning/Roster erwartet wird
|
||||
rosterSteamIds: Set<string>
|
||||
setRosterSteamIds: (ids: string[]) => void
|
||||
clearRoster: () => void
|
||||
|
||||
// 👇 NEU: komme ich in der WS-"players"-Liste vor?
|
||||
isOnline: boolean
|
||||
setOnline: (v: boolean) => void
|
||||
}
|
||||
|
||||
export const useTelemetryStore = create<TelemetryState>((set) => ({
|
||||
@ -23,4 +30,8 @@ export const useTelemetryStore = create<TelemetryState>((set) => ({
|
||||
rosterSteamIds: new Set<string>(),
|
||||
setRosterSteamIds: (ids) => set({ rosterSteamIds: new Set(ids ?? []) }),
|
||||
clearRoster: () => set({ rosterSteamIds: new Set() }),
|
||||
|
||||
// 👇 NEU
|
||||
isOnline: false,
|
||||
setOnline: (v) => set({ isOnline: v }),
|
||||
}))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user