updated gamebanner

This commit is contained in:
Linrador 2025-10-11 20:05:36 +02:00
parent 2dc92c55c2
commit 72f9fcb8f6
13 changed files with 655 additions and 372 deletions

33
package-lock.json generated
View File

@ -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",

View File

@ -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"

View File

@ -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}
>

View 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
}

View File

@ -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)}
/>
)

View File

@ -1,4 +1,5 @@
// /src/app/[locale]/components/GameBannerSpacer.tsx
'use client'
import { useGameBannerStore } from '@/lib/useGameBannerStore'

View File

@ -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">

View File

@ -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>

View File

@ -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
}

View File

@ -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'
}`}
/>
)}

View File

@ -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>

View File

@ -1,3 +1,5 @@
// /src/app/api/cs2/server/live/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

View File

@ -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 }),
}))