diff --git a/package-lock.json b/package-lock.json index a79b14c..b1fcc67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4eaadc8..558da2e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/app/[locale]/components/GameBanner.tsx b/src/app/[locale]/components/GameBanner.tsx index 918dac9..53083b5 100644 --- a/src/app/[locale]/components/GameBanner.tsx +++ b/src/app/[locale]/components/GameBanner.tsx @@ -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 { 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) => 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(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(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>(new Set()) + const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null }) + const [connectHref, setConnectHref] = useState(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(null) + const wsRef = useRef(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(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(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 = () => (
Map: {pretty.map} - Phase: {pretty.phase} Score: {pretty.score} - {tGameBanner('player-connected')}: {shownConnected} / {totalExpected} + {tGameBanner('player-connected')}: {shownConnected} / {participants.length}
) 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 (
- {/* Hintergrundbild (Map) */} - {bgUrl && ( + {/* Hintergrundbild */} + {(bgUrl ?? pickMapImageFromOptions(mapKey)) && (
- {isConnected ? (serverLabel ?? 'CS2 Server') : tGameBanner('not-connected')} + {variant === 'connected' ? 'Verbunden' : tGameBanner('not-connected')}
@@ -235,19 +348,19 @@ export default function GameBanner(props: Props) { - {isConnected ? ( + {variant === 'connected' ? ( - - - {isAdmin && match.matchType === 'community' && ( -
- - -
- )} -
- {/* HEADER */}
{/* Map-BG */} @@ -842,29 +818,68 @@ export function MatchDetails({match, initialNow}: { match: Match; initialNow: nu }} /> - {/* Content */} -
- {/* Meta-Zeile */} -
-
- - {match.matchType || 'match'} - - {dateString && ( - <> - - - + {/* 🔼 Topbar: links/rechts normal, Mitte absolut zentriert */} +
+
+ {/* Links: Zurück */} +
+ + + +
+ + {/* Mitte: Meta-Chips (immer exakt mittig) */} +
+
+ + {match.matchType || 'match'} + + {dateString && ( + <> + + + + )} + + + Best of {bestOf} + +
+
+ + {/* Rechts: Admin-Aktionen */} +
+ {isAdmin && match.matchType === 'community' && ( +
+ + +
)} - - - Best of {bestOf} -
+
+ {/* Content */} +
{/* 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 ( -
+
{/* Team A */}
diff --git a/src/app/[locale]/components/TeamMemberView.tsx b/src/app/[locale]/components/TeamMemberView.tsx index f418ace..2f95efd 100644 --- a/src/app/[locale]/components/TeamMemberView.tsx +++ b/src/app/[locale]/components/TeamMemberView.tsx @@ -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({ )} {joinPolicy === 'INVITE_ONLY' ? 'Nur Einladung' : 'Mit Genehmigung'} {savingPolicy && ( - + )} {policySaved && !savingPolicy && } diff --git a/src/app/[locale]/components/TelemetrySocket.tsx b/src/app/[locale]/components/TelemetrySocket.tsx index dd92a2e..024ef2b 100644 --- a/src/app/[locale]/components/TelemetrySocket.tsx +++ b/src/app/[locale]/components/TelemetrySocket.tsx @@ -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) => 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>(new Set()) - const [mapKeyForUi, setMapKeyForUi] = useState(null) - const [score, setScore] = useState<{ a: number | null; b: number | null }>({ a: null, b: null }) - // connect uri + server name - const [serverId, setServerId] = useState(null) - const [connectHref, setConnectHref] = useState(null) - const [serverName, setServerName] = useState(null) - - // ws - const aliveRef = useRef(true) - const retryRef = useRef(null) - const wsRef = useRef(null) - - // dock element (unter dem Main) - const [dockEl, setDockEl] = useState(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(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(null) + const wsRef = useRef(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([myId]) : new Set()) - - 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 = ( - - ) - - return dockEl ? createPortal(bannerEl, dockEl) : bannerEl + // ⬇️ WICHTIG: Kein Banner-Rendering mehr hier. UI kommt aus GameBannerHost. + return null } diff --git a/src/app/[locale]/components/radar/TeamSidebar.tsx b/src/app/[locale]/components/radar/TeamSidebar.tsx index 273bbf5..297ec5d 100644 --- a/src/app/[locale]/components/radar/TeamSidebar.tsx +++ b/src/app/[locale]/components/radar/TeamSidebar.tsx @@ -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 ' }`} />
@@ -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' }`} /> )} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 1e3fcec..f304f9a 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -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) {
{children}
- + {/* ⬅️ nur noch diese */}
diff --git a/src/app/api/cs2/server/live/route.ts b/src/app/api/cs2/server/live/route.ts index 553bc4e..1865c27 100644 --- a/src/app/api/cs2/server/live/route.ts +++ b/src/app/api/cs2/server/live/route.ts @@ -1,3 +1,5 @@ +// /src/app/api/cs2/server/live/route.ts + import { NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' diff --git a/src/lib/useTelemetryStore.ts b/src/lib/useTelemetryStore.ts index bb8d038..d66a152 100644 --- a/src/lib/useTelemetryStore.ts +++ b/src/lib/useTelemetryStore.ts @@ -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 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((set) => ({ @@ -23,4 +30,8 @@ export const useTelemetryStore = create((set) => ({ rosterSteamIds: new Set(), setRosterSteamIds: (ids) => set({ rosterSteamIds: new Set(ids ?? []) }), clearRoster: () => set({ rosterSteamIds: new Set() }), + + // 👇 NEU + isOnline: false, + setOnline: (v) => set({ isOnline: v }), }))