From c692cefb220b6f3d4ca9664789d0e0e0b689fbe5 Mon Sep 17 00:00:00 2001 From: Linrador Date: Mon, 22 Sep 2025 21:12:25 +0200 Subject: [PATCH] updated --- .../api/matches/[matchId]/mapvote/route.ts | 70 ++-- src/app/api/matches/[matchId]/meta/route.ts | 80 +++- src/app/api/matches/current/route.ts | 65 +++ src/app/components/EditMatchMetaModal.tsx | 371 +++++++++++------- src/app/components/MapVoteBanner.tsx | 45 +-- src/app/components/MapVotePanel.tsx | 27 +- src/app/components/MatchDetails.tsx | 4 +- src/app/components/MatchReadyOverlay.tsx | 140 +++---- src/app/components/TelemetrySocket.tsx | 94 ++--- 9 files changed, 545 insertions(+), 351 deletions(-) create mode 100644 src/app/api/matches/current/route.ts diff --git a/src/app/api/matches/[matchId]/mapvote/route.ts b/src/app/api/matches/[matchId]/mapvote/route.ts index 36f2e4a..70b80ed 100644 --- a/src/app/api/matches/[matchId]/mapvote/route.ts +++ b/src/app/api/matches/[matchId]/mapvote/route.ts @@ -348,26 +348,10 @@ function uniq(arr: T[]) { } function collectParticipants(match: any): string[] { - const fromMatchPlayers = - (match.players ?? []) - .map((mp: any) => mp?.user?.steamId) - .filter(Boolean) - - const fromTeamUsersA = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean) - const fromTeamUsersB = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean) - - const fromActiveA = Array.isArray(match.teamA?.activePlayers) ? match.teamA.activePlayers : [] - const fromActiveB = Array.isArray(match.teamB?.activePlayers) ? match.teamB.activePlayers : [] - - const leaderA = match.teamA?.leader?.steamId ? [match.teamA.leader.steamId] : [] - const leaderB = match.teamB?.leader?.steamId ? [match.teamB.leader.steamId] : [] - - return uniq([ - ...fromMatchPlayers, - ...fromTeamUsersA, ...fromTeamUsersB, - ...fromActiveA, ...fromActiveB, - ...leaderA, ...leaderB, - ]) + const a = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean) + const b = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean) + // ❗ keine Leader/activePlayers/match.players mehr – NUR echte Teamspieler + return Array.from(new Set([...a, ...b])) } async function persistMatchPlayers(match: any) { @@ -542,27 +526,11 @@ async function exportMatchToSftpDirect(match: any, vote: any) { data: { exportedAt: new Date() }, }) } - - // ⬇️ OPTIONAL: cs2MatchId + exportedAt im Match speichern - if (typeof json.matchid === 'number') { - await prisma.match.update({ - where: { id: match.id }, - data: { cs2MatchId: json.matchid, exportedAt: new Date() }, - }) - } else { - await prisma.match.update({ - where: { id: match.id }, - data: { exportedAt: new Date() }, - }) - } - } catch (err) { console.error('[mapvote] Export fehlgeschlagen:', err) } } - - /* ---------- kleine Helfer für match-ready Payload ---------- */ function deriveChosenSteps(vote: any) { @@ -649,8 +617,19 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st if (!current) { // Kein Schritt mehr -> Vote abschließen - await prisma.mapVote.update({ where: { id: vote.id }, data: { locked: true } }) - const updated = await prisma.mapVote.findUnique({ where: { id: vote.id }, include: { steps: true } }) + await prisma.mapVote.update({ + where: { id: vote.id }, + data: { + locked: true, + adminEditingBy: null, + adminEditingSince: null, + }, + }) + + const updated = await prisma.mapVote.findUnique({ + where: { id: vote.id }, + include: { steps: true }, + }) await sendServerSSEMessage({ type: 'map-vote-updated', @@ -700,7 +679,13 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st }) await tx.mapVote.update({ where: { id: vote.id }, - data : { currentIdx: vote.currentIdx + 1, locked: true }, + data : { + currentIdx: vote.currentIdx + 1, + locked: true, + // ➜ Admin-Edit beenden + adminEditingBy: null, + adminEditingSince: null, + }, }) }) @@ -796,7 +781,12 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st await tx.mapVote.update({ where: { id: after.id }, - data : { currentIdx: idx, locked }, + data : { + currentIdx: idx, + locked, + // ➜ Nur wenn jetzt abgeschlossen: Admin-Edit beenden + ...(locked ? { adminEditingBy: null, adminEditingSince: null } : {}), + }, }) }) diff --git a/src/app/api/matches/[matchId]/meta/route.ts b/src/app/api/matches/[matchId]/meta/route.ts index cf2326c..b5721ce 100644 --- a/src/app/api/matches/[matchId]/meta/route.ts +++ b/src/app/api/matches/[matchId]/meta/route.ts @@ -50,22 +50,10 @@ function voteOpensAt(base: Date, leadMinutes: number) { return new Date(base.getTime() - leadMinutes * 60_000) } -// Steps-Builder für BO1/BO3/BO5 -function buildSteps(bestOf: 1 | 3 | 5, firstTeamId: string | null, secondTeamId: string | null) { +// Steps-Builder für BO3/BO5 +function buildSteps(bestOf: 3 | 5, firstTeamId: string | null, secondTeamId: string | null) { const A = firstTeamId const B = secondTeamId - if (bestOf === 1) { - // Klassischer BO1: 6x Ban (A/B abwechselnd), danach Decider - return [ - { order: 0, action: 'BAN', teamId: A }, - { order: 1, action: 'BAN', teamId: B }, - { order: 2, action: 'BAN', teamId: A }, - { order: 3, action: 'BAN', teamId: B }, - { order: 4, action: 'BAN', teamId: A }, - { order: 5, action: 'BAN', teamId: B }, - { order: 6, action: 'DECIDER', teamId: null }, - ] as const - } if (bestOf === 3) { // 2x Ban, 2x Pick, 2x Ban, Decider return [ @@ -108,7 +96,6 @@ export async function PUT( teamAId, teamBId, matchDate, - map, voteLeadMinutes, // optional demoDate, bestOf: bestOfRaw, // <- NEU @@ -116,7 +103,7 @@ export async function PUT( // BestOf validieren (nur 1/3/5 zulassen) const bestOf = - [1, 3, 5].includes(Number(bestOfRaw)) ? (Number(bestOfRaw) as 1 | 3 | 5) : undefined + [3, 5].includes(Number(bestOfRaw)) ? (Number(bestOfRaw) as 3 | 5) : undefined try { const match = await prisma.match.findUnique({ @@ -140,7 +127,6 @@ export async function PUT( const updateData: any = {} if (typeof title !== 'undefined') updateData.title = title if (typeof matchType === 'string') updateData.matchType = matchType - if (typeof map !== 'undefined') updateData.map = map if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten @@ -318,7 +304,6 @@ export async function PUT( teamBId: updated.teamBId, matchDate: updated.matchDate, demoDate: updated.demoDate, - map: updated.map, bestOf: updated.bestOf, mapVote: updated.mapVote, }, { headers: { 'Cache-Control': 'no-store' } }) @@ -327,3 +312,62 @@ export async function PUT( return NextResponse.json({ error: 'Failed to update match meta' }, { status: 500 }) } } + + +export async function GET( + req: NextRequest, + { params }: { params: { matchId: string } } +) { + const id = params?.matchId + if (!id) { + return NextResponse.json({ error: 'Missing matchId in route params' }, { status: 400 }) + } + + // Falls Lesen nur für Admin/Leader erlaubt sein soll, Auth prüfen: + const session = await getServerSession(authOptions(req)) + const me = session?.user + if (!me?.steamId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) + } + + try { + const match = await prisma.match.findUnique({ + where: { id }, + include: { + teamA: { include: { leader: true } }, + teamB: { include: { leader: true } }, + mapVote: { include: { steps: true } }, // steps optional, wird oft praktisch + }, + }) + if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 }) + + // Optional: Zugriff nur für Admin/Leader erlauben (symmetrisch zu PUT) + const isAdmin = !!me.isAdmin + const isLeaderA = !!match.teamAId && match.teamA?.leader?.steamId === me.steamId + const isLeaderB = !!match.teamBId && match.teamB?.leader?.steamId === me.steamId + if (!isAdmin && !isLeaderA && !isLeaderB) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + return NextResponse.json({ + id: match.id, + title: match.title, + matchType: match.matchType, + teamAId: match.teamAId, + teamBId: match.teamBId, + matchDate: match.matchDate, + demoDate: match.demoDate, + bestOf: match.bestOf, + mapVote: { + id: match.mapVote?.id ?? null, + leadMinutes: match.mapVote?.leadMinutes ?? 60, + opensAt: match.mapVote?.opensAt ?? null, + // steps nur mitgeben, wenn du sie brauchst: + steps: match.mapVote?.steps ?? [], + }, + }, { headers: { 'Cache-Control': 'no-store' } }) + } catch (err) { + console.error(`GET /matches/${id}/meta failed:`, err) + return NextResponse.json({ error: 'Failed to load match meta' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/src/app/api/matches/current/route.ts b/src/app/api/matches/current/route.ts new file mode 100644 index 0000000..5fba017 --- /dev/null +++ b/src/app/api/matches/current/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/app/lib/prisma' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function uniq(arr: T[]) { return Array.from(new Set(arr)) } + +/** + * „Aktuelles Match“: + * - Du kannst hier deine eigene Definition hinterlegen (z.B. Match mit exportiertem json, oder demnächst startend). + * - Beispiel: Das zuletzt exportierte (cs2MatchId gesetzt) oder das neueste mit matchDate in der Zukunft/Gegenwart. + */ +async function findCurrentMatch() { + // 1) Bevorzugt: zuletzt exportiertes Match + const exported = await prisma.match.findFirst({ + where: { cs2MatchId: { not: null } }, + orderBy: { exportedAt: 'desc' }, + include: { + teamA: { include: { leader: true } }, + teamB: { include: { leader: true } }, + teamAUsers: true, + teamBUsers: true, + players: true, // MatchPlayer + }, + }) + if (exported) return exported + + // 2) Fallback: Nächstes geplantes / gestartetes Match + const now = new Date() + const upcoming = await prisma.match.findFirst({ + where: { matchDate: { gte: new Date(now.getTime() - 3 * 60 * 60 * 1000) } }, // ab 3h zurück + orderBy: { matchDate: 'asc' }, + include: { + teamA: { include: { leader: true } }, + teamB: { include: { leader: true } }, + teamAUsers: true, + teamBUsers: true, + players: true, + }, + }) + return upcoming +} + +export async function GET(_req: NextRequest) { + const match = await findCurrentMatch() + if (!match) { + return NextResponse.json({ matchId: null, steamIds: [], total: 0 }, { headers: { 'Cache-Control': 'no-store' } }) + } + + // Roster bestimmen: bevorzugt MatchPlayer, sonst Team-User + Leader + let steamIds: string[] = (match.players ?? []).map(mp => mp.steamId) + if (steamIds.length === 0) { + const fromA = (match.teamAUsers ?? []).map(u => u.steamId) + const fromB = (match.teamBUsers ?? []).map(u => u.steamId) + const leaderA = match.teamA?.leader?.steamId ? [match.teamA.leader.steamId] : [] + const leaderB = match.teamB?.leader?.steamId ? [match.teamB.leader.steamId] : [] + steamIds = uniq([...fromA, ...fromB, ...leaderA, ...leaderB].filter(Boolean)) + } + + return NextResponse.json( + { matchId: match.id, steamIds, total: steamIds.length }, + { headers: { 'Cache-Control': 'no-store' } } + ) +} diff --git a/src/app/components/EditMatchMetaModal.tsx b/src/app/components/EditMatchMetaModal.tsx index 4e0fa0d..7718ecd 100644 --- a/src/app/components/EditMatchMetaModal.tsx +++ b/src/app/components/EditMatchMetaModal.tsx @@ -1,10 +1,11 @@ -// app/components/EditMatchMetaModal.tsx +// /src/app/components/EditMatchMetaModal.tsx 'use client' import { useEffect, useMemo, useRef, useState } from 'react' import Modal from '@/app/components/Modal' import Alert from '@/app/components/Alert' import Select from '@/app/components/Select' +import LoadingSpinner from '@/app/components/LoadingSpinner' // ⬅️ NEU type TeamOption = { id: string; name: string; logo?: string | null } @@ -18,10 +19,10 @@ type Props = { defaultTeamAName?: string | null defaultTeamBName?: string | null defaultDateISO?: string | null - defaultMap?: string | null // bleibt im Typ für Kompatibilität, wird aber nicht mehr genutzt + defaultMap?: string | null // nur aus Kompatibilitätsgründen noch vorhanden defaultVoteLeadMinutes?: number onSaved?: () => void - defaultBestOf?: 1 | 3 | 5 + defaultBestOf?: 3 | 5 } export default function EditMatchMetaModal({ @@ -34,108 +35,159 @@ export default function EditMatchMetaModal({ defaultTeamAName, defaultTeamBName, defaultDateISO, - // defaultMap, // nicht mehr genutzt + // defaultMap, defaultVoteLeadMinutes = 60, onSaved, defaultBestOf = 3, }: Props) { + /* ───────── Utils ───────── */ + const normalizeBestOf = (bo: unknown): 3 | 5 => (Number(bo) === 5 ? 5 : 3) + const toDatetimeLocal = (iso?: string | null) => { + if (!iso) return '' + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad( + d.getHours() + )}:${pad(d.getMinutes())}` + } + + /* ───────── Local state ───────── */ const [title, setTitle] = useState(defaultTitle ?? '') const [teamAId, setTeamAId] = useState(defaultTeamAId ?? '') const [teamBId, setTeamBId] = useState(defaultTeamBId ?? '') const [voteLead, setVoteLead] = useState(defaultVoteLeadMinutes) - - const [date, setDate] = useState(() => { - if (!defaultDateISO) return '' - const d = new Date(defaultDateISO) - const pad = (n: number) => String(n).padStart(2, '0') - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` - }) - - // Nur noch BestOf editierbar - const [bestOf, setBestOf] = useState<1 | 3 | 5>(defaultBestOf) + const [date, setDate] = useState(toDatetimeLocal(defaultDateISO)) + const [bestOf, setBestOf] = useState<3 | 5>(normalizeBestOf(defaultBestOf)) const [teams, setTeams] = useState([]) const [loadingTeams, setLoadingTeams] = useState(false) + + const [loadingMeta, setLoadingMeta] = useState(false) + const [error, setError] = useState(null) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) - const [error, setError] = useState(null) - const openedOnceRef = useRef(false) + // BO-Referenz aus /meta, um den Hinweis korrekt zu steuern + const [metaBestOf, setMetaBestOf] = useState<3 | 5 | null>(null) - // Teams laden + // Dedupe-Refs (StrictMode) + const teamsFetchedRef = useRef(false) + const metaFetchedRef = useRef(false) + + /* ───────── Reset bei Schließen ───────── */ + useEffect(() => { + if (!show) { + teamsFetchedRef.current = false + metaFetchedRef.current = false + setSaved(false) + setError(null) + } + }, [show]) + + /* ───────── Teams laden ───────── */ useEffect(() => { if (!show) return + if (teamsFetchedRef.current) return + + let alive = true setLoadingTeams(true) + ;(async () => { try { const res = await fetch('/api/teams', { cache: 'no-store' }) const data = res.ok ? await res.json() : [] + if (!alive) return const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? []) setTeams((list ?? []).filter((t: any) => t?.id && t?.name)) - } catch (e) { + } catch (e: any) { + if (!alive) return console.error('[EditMatchMetaModal] load teams failed:', e) setTeams([]) } finally { - setLoadingTeams(false) + if (alive) { + setLoadingTeams(false) + teamsFetchedRef.current = true // Flag erst nach erfolgreichem Lauf + } } })() + + return () => { + alive = false + teamsFetchedRef.current = false + } }, [show]) - // Defaults beim Öffnen (einmal) + /* ───────── Meta neu laden beim Öffnen ───────── */ useEffect(() => { - if (!show) { openedOnceRef.current = false; return } - if (openedOnceRef.current) return - openedOnceRef.current = true + if (!show) return + if (metaFetchedRef.current) return - setTitle(defaultTitle ?? '') - setTeamAId(defaultTeamAId ?? '') - setTeamBId(defaultTeamBId ?? '') - setVoteLead(defaultVoteLeadMinutes) - - if (defaultDateISO) { - const d = new Date(defaultDateISO) - const pad = (n: number) => String(n).padStart(2, '0') - setDate(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`) - } else { - setDate('') - } - - setBestOf(defaultBestOf ?? 3) - setSaved(false) + let alive = true + setLoadingMeta(true) setError(null) - }, [ - show, - defaultTitle, - defaultTeamAId, - defaultTeamBId, - defaultDateISO, - defaultVoteLeadMinutes, - defaultBestOf, - ]) - // Optionen + ;(async () => { + try { + const res = await fetch(`/api/matches/${matchId}/meta`, { cache: 'no-store' }) + if (!res.ok) { + const j = await res.json().catch(() => ({})) + throw new Error(j?.error || `Meta-Load failed (${res.status})`) + } + const j = await res.json() + if (!alive) return + + setTitle(j?.title ?? '') + setTeamAId(j?.teamAId ?? '') + setTeamBId(j?.teamBId ?? '') + setDate(toDatetimeLocal(j?.matchDate ?? j?.demoDate ?? null)) + setVoteLead( + Number.isFinite(Number(j?.mapVote?.leadMinutes)) ? Number(j.mapVote.leadMinutes) : 60 + ) + + const boFromMeta = normalizeBestOf(j?.bestOf) + setBestOf(boFromMeta) + setMetaBestOf(boFromMeta) + setSaved(false) + } catch (e: any) { + if (!alive) return + console.error('[EditMatchMetaModal] reload meta failed:', e) + setError(e?.message || 'Konnte aktuelle Match-Metadaten nicht laden.') + } finally { + if (alive) { + setLoadingMeta(false) + metaFetchedRef.current = true // Flag erst nach Abschluss setzen + } + } + })() + + return () => { + alive = false + metaFetchedRef.current = false + } + }, [show, matchId]) + + /* ───────── Optionen für Selects ───────── */ const teamOptionsA = useMemo( - () => teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.name })), + () => teams.filter((t) => t.id !== teamBId).map((t) => ({ value: t.id, label: t.name })), [teams, teamBId] ) const teamOptionsB = useMemo( - () => teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.name })), + () => teams.filter((t) => t.id !== teamAId).map((t) => ({ value: t.id, label: t.name })), [teams, teamAId] ) - // Hinweis-Flag: Best Of geändert? - const defaultBestOfNormalized = (defaultBestOf ?? 3) as 1 | 3 | 5 - const bestOfChanged = bestOf !== defaultBestOfNormalized + /* ───────── Hinweis-Flag nur vs. /meta ───────── */ + const showBoChangedHint = metaBestOf !== null && bestOf !== metaBestOf - // Validation + /* ───────── Validation ───────── */ const canSave = useMemo(() => { - if (saving) return false + if (saving || loadingMeta) return false if (!date) return false if (teamAId && teamBId && teamAId === teamBId) return false return true - }, [saving, date, teamAId, teamBId]) + }, [saving, loadingMeta, date, teamAId, teamBId]) - // Save → nur bestOf wird (zusätzlich) übertragen; Server resettet MapVote bei Änderung + /* ───────── Save ───────── */ const handleSave = async () => { setSaving(true) setError(null) @@ -146,7 +198,7 @@ export default function EditMatchMetaModal({ teamBId: teamBId || null, matchDate: date ? new Date(date).toISOString() : null, voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60, - bestOf, // <- wichtig + bestOf, } const res = await fetch(`/api/matches/${matchId}/meta`, { @@ -170,8 +222,11 @@ export default function EditMatchMetaModal({ } } - const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …') - const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …') + const teamAPlaceholder = loadingTeams ? 'Teams laden …' : defaultTeamAName || 'Team A wählen …' + const teamBPlaceholder = loadingTeams ? 'Teams laden …' : defaultTeamBName || 'Team B wählen …' + + // ⬇️ Neu: Wenn Meta lädt, zeigen wir ausschließlich den Spinner statt der Form + const showOnlySpinner = loadingMeta && show return ( - {error && ( + {error && !loadingMeta && ( {error} )} -
- {/* Titel */} -
- - setTitle(e.target.value)} - placeholder="z.B. Scrim vs. XYZ" - /> + {showOnlySpinner ? ( +
+
- - {/* Team A */} -
- - -
- - {/* Datum/Uhrzeit */} -
- - setDate(e.target.value)} - /> -

- Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}). -

-
- - {/* Vote-Lead */} -
- - setVoteLead(Number(e.target.value))} - /> -

- Zeit vor Matchstart, zu der das Vote öffnet (Standard 60). -

-
- - {/* Nur noch Best Of */} -
- -
- {[1, 3, 5].map(bo => ( - - ))} + ) : ( +
+ {/* Titel */} +
+ + setTitle(e.target.value)} + placeholder="z.B. Scrim vs. XYZ" + disabled={loadingMeta} + />
- {bestOfChanged && ( - - Du hast den Modus von BO{defaultBestOfNormalized} auf BO{bestOf} geändert. - Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden verworfen). - - )} + {/* Team A */} +
+ + +
+ + {/* Datum/Uhrzeit */} +
+ + setDate(e.target.value)} + disabled={loadingMeta} + /> +

+ Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}). +

+
+ + {/* Vote-Lead */} +
+ + setVoteLead(Number(e.target.value))} + disabled={loadingMeta} + /> +

+ Zeit vor Matchstart, zu der das Vote öffnet (Standard 60). +

+
+ + {/* Best Of */} +
+ +
+ {[3, 5].map((bo) => ( + + ))} +
+ + {metaBestOf !== null && bestOf !== metaBestOf && ( + + Du hast den Modus von BO{metaBestOf} auf BO{bestOf} geändert. + Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden + verworfen). + + )} +
-
+ )} ) } diff --git a/src/app/components/MapVoteBanner.tsx b/src/app/components/MapVoteBanner.tsx index 90a73d9..e3b9cfd 100644 --- a/src/app/components/MapVoteBanner.tsx +++ b/src/app/components/MapVoteBanner.tsx @@ -134,6 +134,9 @@ export default function MapVoteBanner({ const isLocked = !!state?.locked const isVotingOpen = isOpen && !isLocked + const isEnded = !!state?.locked + const isLive = isOpen && !isEnded + const current = state?.steps?.[state?.currentIndex ?? 0] const whoIsUp = current?.teamId ? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name) @@ -152,24 +155,24 @@ export default function MapVoteBanner({ const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`) - // Farblogik: locked → grün, offen → gelb, noch geschlossen → neutral - const ringClass = isLocked - ? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg' - : isVotingOpen - ? 'ring-1 ring-yellow-500/20 hover:ring-yellow-500/35 hover:shadow-lg' + // Vorher: locked -> grün, live -> gelb + // Neu: live -> grün, locked -> grau + const ringClass = isLive + ? 'ring-1 ring-green-500/20 hover:ring-green-500/35 hover:shadow-lg' + : isEnded + ? 'ring-1 ring-neutral-500/15 hover:ring-neutral-500/25 hover:shadow-md' : 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md' - const bubbleClass = isLocked + const bubbleClass = isLive ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200' - : isVotingOpen - ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100' + : isEnded + ? 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700/40 dark:text-neutral-200' : 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700/40 dark:text-neutral-200' - const gradientClass = isLocked + const gradientClass = isLive ? 'mapVoteGradient--green' - : isVotingOpen - ? 'mapVoteGradient--yellow' - : 'mapVoteGradient--none' + : 'mapVoteGradient--none' // beendet und noch-nicht-offen: kein Effekt + return (
Map-Vote
Modus: BO{match.bestOf ?? state?.bestOf ?? 3} - {state?.locked + {isEnded ? ' • Auswahl fixiert' - : isVotingOpen + : isLive ? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft') : ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`}
@@ -210,19 +213,17 @@ export default function MapVoteBanner({
- {state?.locked ? ( - + {isEnded ? ( + Voting abgeschlossen - ) : isVotingOpen ? ( - + ) : isLive ? ( + {iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'} ) : ( - + Öffnet in {mounted ? formatCountdown(msToOpen) : '–:–:–'} )} diff --git a/src/app/components/MapVotePanel.tsx b/src/app/components/MapVotePanel.tsx index dfc02e1..ac8caec 100644 --- a/src/app/components/MapVotePanel.tsx +++ b/src/app/components/MapVotePanel.tsx @@ -880,6 +880,15 @@ export default function MapVotePanel({ match }: Props) { ? chosenSteps.slice(0, idx).filter(s => s.action === 'pick').length + 1 : null + // ➜ NEU: Für den Decider ebenfalls eine Nummer anzeigen (Slot-Nummer) + const displayNumber = + action === 'pick' + ? pickNumber + : action === 'decider' + ? idx + 1 + : null + + const pickedByLeft = (action === 'pick' || action === 'decider') && pickTeamId === leftTeamId const pickedByRight = (action === 'pick' || action === 'decider') && pickTeamId === rightTeamId const cornerLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null @@ -924,15 +933,21 @@ export default function MapVotePanel({ match }: Props) { /> )} - {/* Pick-Nummer oben links (nur für Picks) */} - {typeof pickNumber === 'number' && ( + {/* Nummer oben links (für Pick UND Decider) */} + {typeof displayNumber === 'number' && ( - {pickNumber} + {displayNumber} )} diff --git a/src/app/components/MatchDetails.tsx b/src/app/components/MatchDetails.tsx index b00c634..640b656 100644 --- a/src/app/components/MatchDetails.tsx +++ b/src/app/components/MatchDetails.tsx @@ -562,9 +562,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: defaultDateISO={match.matchDate ?? match.demoDate ?? null} defaultMap={match.map ?? null} defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60} - // ⬇️ neu: - defaultBestOf={(match.bestOf as 1 | 3 | 5) ?? 3} - defaultSeries={extractSeriesMaps(match)} // Array mit map-Keys (kann '' enthalten) + defaultBestOf={Number(match.bestOf) === 5 ? 5 : 3} onSaved={() => { setTimeout(() => router.refresh(), 0) }} /> )} diff --git a/src/app/components/MatchReadyOverlay.tsx b/src/app/components/MatchReadyOverlay.tsx index 1933821..999209a 100644 --- a/src/app/components/MatchReadyOverlay.tsx +++ b/src/app/components/MatchReadyOverlay.tsx @@ -7,7 +7,6 @@ import { useSSEStore } from '@/app/lib/useSSEStore' import { useSession } from 'next-auth/react' import LoadingSpinner from './LoadingSpinner' import { MAP_OPTIONS } from '../lib/mapOptions' -import type { MapOption } from '../lib/mapOptions' type Props = { open: boolean @@ -42,14 +41,23 @@ export default function MatchReadyOverlay({ connectHref }: Props) { const { data: session } = useSession() - const mySteamId = session?.user?.steamId + const mySteamId = session?.user?.steamId ? String(session.user.steamId) : null const { lastEvent } = useSSEStore() - + // --- Team-Guard: nur Team A/B + const [allowedIds, setAllowedIds] = useState(null) + const iAmAllowed = useMemo(() => { + if (!mySteamId) return false + if (allowedIds === null) return true // bis die Liste da ist, nicht blocken + return allowedIds.includes(mySteamId) + }, [allowedIds, mySteamId]) + + // Verbindungslink const ENV_CONNECT_HREF = process.env.NEXT_PUBLIC_CONNECT_HREF const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000' const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF + // Timer const [now, setNow] = useState(() => Date.now()) const [startedAt] = useState(() => Date.now()) const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt]) @@ -59,30 +67,29 @@ export default function MatchReadyOverlay({ // UI-States const [accepted, setAccepted] = useState(false) const [finished, setFinished] = useState(false) - const [showWaitHint, setShowWaitHint] = useState(false) // ⬅️ nutzt du unten zum Ausblenden des Countdowns + const [showWaitHint, setShowWaitHint] = useState(false) const [connecting, setConnecting] = useState(false) - const isVisible = open || accepted || showWaitHint const [showConnectHelp, setShowConnectHelp] = useState(false) const [showBackdrop, setShowBackdrop] = useState(false) const [showContent, setShowContent] = useState(false) - // Ready-Listen-Status + // Sichtbarkeit (ohne early return!) + const isVisibleBase = open || accepted || showWaitHint + const shouldRender = Boolean(isVisibleBase && iAmAllowed) + + // Ready-Status type Participant = { steamId: string; name: string; avatar: string; team: 'A' | 'B' | null } const [participants, setParticipants] = useState([]) const [readyMap, setReadyMap] = useState>({}) const [total, setTotal] = useState(0) const [countReady, setCountReady] = useState(0) - // Presence-Map (SSE) + // Presence (SSE) const [statusMap, setStatusMap] = useState>({}) - const prevCountReadyRef = useRef(0) - const ignoreNextIncreaseRef = useRef(false) - // ----- AUDIO ----- const beepsRef = useRef | null>(null) const audioStartedRef = useRef(false) - const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } } const ensureAudioUnlocked = async () => { @@ -127,33 +134,35 @@ export default function MatchReadyOverlay({ } try { onTimeout?.() } catch {} } - // mini delay für UI Feedback setTimeout(doConnect, 200) }, [finished, effectiveConnectHref, onTimeout]) - // NUR nach Acceptance laden/aktualisieren + // Ready-API nur nach Accept const loadReady = useCallback(async () => { try { const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' }) if (!r.ok) return const j = await r.json() - setParticipants(j.participants ?? []) + const parts: Participant[] = j.participants ?? [] + setParticipants(parts) setReadyMap(j.ready ?? {}) setTotal(j.total ?? 0) setCountReady(j.countReady ?? 0) + + // Team-Guard füttern + const ids = parts.map(p => String(p.steamId)).filter(Boolean) + if (ids.length) setAllowedIds(ids) } catch {} }, [matchId]) - // ---- Accept-Handling ---- + // Accept const postingRef = useRef(false) - const onAcceptClick = async () => { if (postingRef.current) return postingRef.current = true try { stopBeeps() playMenuAccept() - const res = await fetch(`/api/matches/${matchId}/ready`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Ready-Accept': '1' }, @@ -161,7 +170,6 @@ export default function MatchReadyOverlay({ body: JSON.stringify({ intent: 'accept' }), }) if (!res.ok) return - setAccepted(true) try { await onAccept() } catch {} await loadReady() @@ -170,8 +178,7 @@ export default function MatchReadyOverlay({ } } - - // ⬇️ NEU: nach 30s „Es lädt nicht?“ anzeigen + // „Es lädt nicht?“ nach 30s useEffect(() => { let id: number | null = null if (connecting) { @@ -181,17 +188,15 @@ export default function MatchReadyOverlay({ return () => { if (id) window.clearTimeout(id) } }, [connecting]) - // Backdrop zuerst faden, dann Content + // Backdrop → Content useEffect(() => { - if (!isVisible) { setShowBackdrop(false); setShowContent(false); return } + if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return } setShowBackdrop(true) - const id = setTimeout(() => setShowContent(true), 300) // vorher: 2000 + const id = setTimeout(() => setShowContent(true), 300) return () => clearTimeout(id) - }, [isVisible]) + }, [shouldRender]) - if (!isVisible) return null - - // Nach Accept ein kurzer Refresh + // Nach Accept kurzer Refresh useEffect(() => { if (!accepted) return const id = setTimeout(loadReady, 250) @@ -199,12 +204,19 @@ export default function MatchReadyOverlay({ }, [accepted, loadReady]) // SSE - const { lastEvent: le } = useSSEStore() useEffect(() => { if (!lastEvent) return const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type const payload = (lastEvent as any).payload?.payload ?? (lastEvent as any).payload ?? lastEvent + // participants aus Event übernehmen (falls geschickt) + const payloadParticipants: string[] | undefined = Array.isArray(payload?.participants) + ? payload.participants.map((sid: any) => String(sid)).filter(Boolean) + : undefined + if (payloadParticipants && payloadParticipants.length) { + setAllowedIds(payloadParticipants) + } + if (type === 'ready-updated' && payload?.matchId === matchId) { if (accepted) { const otherSteamId = payload?.steamId as string | undefined @@ -223,15 +235,15 @@ export default function MatchReadyOverlay({ } }, [accepted, lastEvent, matchId, mySteamId, loadReady]) - // ----- simple mount animation flags ----- + // Mount-Animation const [fadeIn, setFadeIn] = useState(false) useEffect(() => { - if (!isVisible) { setFadeIn(false); return } + if (!shouldRender) { setFadeIn(false); return } const id = requestAnimationFrame(() => setFadeIn(true)) return () => cancelAnimationFrame(id) - }, [isVisible]) + }, [shouldRender]) - // ----- motion layer (video/gif) ----- + // Motion-Layer const prefersReducedMotion = useMemo( () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, [] @@ -240,7 +252,7 @@ export default function MatchReadyOverlay({ const [useGif, setUseGif] = useState(() => !!forceGif || !!prefersReducedMotion) useEffect(() => { - if (!isVisible) return + if (!shouldRender) return if (forceGif || prefersReducedMotion) { setUseGif(true); return } const tryPlay = async () => { const v = videoRef.current @@ -259,22 +271,21 @@ export default function MatchReadyOverlay({ } const id = setTimeout(tryPlay, 0) return () => clearTimeout(id) - }, [isVisible, forceGif, prefersReducedMotion]) + }, [shouldRender, forceGif, prefersReducedMotion]) useEffect(() => { - if (isVisible) return + if (shouldRender) return const v = videoRef.current if (v) { try { v.pause() } catch {} v.removeAttribute('src') while (v.firstChild) v.removeChild(v.firstChild) } - }, [isVisible]) + }, [shouldRender]) - // ----- AUDIO: Beeps starten/stoppen ----- - // Beeps erst starten, wenn der Content sichtbar ist + // AUDIO: Beeps starten/stoppen useEffect(() => { - if (!showContent) { // vorher: if (!isVisible) + if (!showContent) { stopBeeps() audioStartedRef.current = false return @@ -300,53 +311,53 @@ export default function MatchReadyOverlay({ })() return () => { cleanup(); stopBeeps() } - }, [showContent]) // vorher: [isVisible] + }, [showContent]) - // ⏩ Sofort verbinden, wenn alle bereit sind + // Auto-Connect wenn alle bereit useEffect(() => { - if (!isVisible) return + if (!shouldRender) return if (total > 0 && countReady >= total && !finished) { startConnectingNow() } - }, [isVisible, total, countReady, finished, startConnectingNow]) + }, [shouldRender, total, countReady, finished, startConnectingNow]) - // ----- countdown / timeout ----- + // Countdown const rafRef = useRef(null) useEffect(() => { - if (!isVisible) return + if (!shouldRender) return const step = () => { const t = Date.now() setNow(t) if (effectiveDeadline - t <= 0 && !finished) { - if (accepted) { - startConnectingNow() - } else { - stopBeeps() - setFinished(true) - setShowWaitHint(true) + if (accepted) { + startConnectingNow() + } else { + stopBeeps() + setFinished(true) + setShowWaitHint(true) + } + return } - return - } rafRef.current = requestAnimationFrame(step) } rafRef.current = requestAnimationFrame(step) return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } - }, [isVisible, effectiveDeadline, accepted, finished, connectHref, onTimeout]) + }, [shouldRender, effectiveDeadline, accepted, finished, onTimeout, startConnectingNow]) - // 🔎 Map-Icon aus MAP_OPTIONS ermitteln + // Map-Icon const mapIconUrl = useMemo(() => { const norm = (s?: string | null) => (s ?? '').trim().toLowerCase() const keyFromBg = /\/maps\/([^/]+)\//.exec(mapBg ?? '')?.[1] - const byKey = keyFromBg ? MAP_OPTIONS.find(o => o.key === keyFromBg) : undefined const byLabel = mapLabel ? MAP_OPTIONS.find(o => norm(o.label) === norm(mapLabel)) : undefined const byImage = mapBg ? MAP_OPTIONS.find(o => o.images.includes(mapBg)) : undefined - const opt = byKey ?? byLabel ?? byImage return opt?.icon ?? '/assets/img/mapicons/map_icon_lobby_mapveto.svg' }, [mapBg, mapLabel]) - // --- UI Helpers --- + // ---------- RENDER ---------- + if (!shouldRender) return null + const ReadyRow = () => (
@@ -402,7 +413,7 @@ export default function MatchReadyOverlay({ return (
- {/* Backdrop: 2s-Fade */} + {/* Backdrop */}
- {/* Content erst nach Backdrop-Delay */} + {/* Content */} {showContent && (
- {/* Deko-Layer (Gif/Video) */} + {/* Motion-Layer */} {useGif ? (
)} - {/* 🔽 NEU: dunkler Gradient wie bei „Gewählte Maps“ */} + {/* Gradient */}
{/* Inhalt */} @@ -498,13 +509,11 @@ export default function MatchReadyOverlay({ )} - {/* Countdown oder Verbinde-Status */} - {/* 🔽 NEU: Countdown ausblenden, wenn der Warte-Hinweis gezeigt wird */} + {/* Countdown / Verbinde-Status */} {!showWaitHint && (
{connecting ? ( showConnectHelp ? ( - // ⬇️ NEU: nach 30s Es lädt nicht? ) : ( - // bisheriges „Verbinde…“ o.key.toLowerCase() === k) if (opt?.label) return opt.label - // Fallback: "de_dust2" -> "Dust 2" 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 @@ -84,8 +84,10 @@ export default function TelemetrySocket() { const phase = useTelemetryStore((s) => s.phase) const setPhase = useTelemetryStore((s) => s.setPhase) - // roster (persisted by ReadyOverlayHost) + // roster store const rosterSet = useMatchRosterStore((s) => s.roster) + const setRoster = useMatchRosterStore((s) => s.setRoster) + const clearRoster = useMatchRosterStore((s) => s.clearRoster) // local telemetry state const [telemetrySet, setTelemetrySet] = useState>(new Set()) @@ -121,7 +123,33 @@ export default function TelemetrySocket() { })() }, []) - // websocket connect + // 🔸 Aktuelles Match + Roster laden (kein matchId-Prop nötig) + const [currentMatchId, setCurrentMatchId] = useState(null) + const { lastEvent } = useSSEStore() + + async function fetchCurrentRoster() { + try { + const r = await fetch('/api/matches/current', { cache: 'no-store' }) + 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 + 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))) { + fetchCurrentRoster() + } + }, [lastEvent]) + + // websocket connect (wie gehabt) useEffect(() => { aliveRef.current = true @@ -146,12 +174,10 @@ export default function TelemetrySocket() { try { msg = JSON.parse(String(ev.data ?? '')) } catch {} if (!msg) return - // --- server name (optional) if (msg.type === 'server' && typeof msg.name === 'string' && msg.name.trim()) { setServerName(msg.name.trim()) } - // --- full roster if (msg.type === 'players' && Array.isArray(msg.players)) { setSnapshot(msg.players) const ids = msg.players.map(sidOf).filter(Boolean) @@ -162,7 +188,6 @@ export default function TelemetrySocket() { } } - // --- incremental roster if (msg.type === 'player_join' && msg.player) { setJoin(msg.player) setTelemetrySet(prev => { @@ -185,7 +210,7 @@ export default function TelemetrySocket() { }) } - // --- map: NUR map + optional serverName, NICHT die Phase übernehmen + // map nur für UI (Phase separat) if (msg.type === 'map' && typeof msg.name === 'string') { const key = msg.name.toLowerCase() setMapKey(key) @@ -195,12 +220,12 @@ export default function TelemetrySocket() { } } - // --- phase: AUSSCHLIESSLICHE Quelle für die Phase + // Phase ausschließlich aus WS if (msg.type === 'phase' && typeof msg.phase === 'string') { setPhase(String(msg.phase).toLowerCase() as any) } - // --- score (unverändert) + // Score if (msg.type === 'score') { const a = Number(msg.team1 ?? msg.ct) const b = Number(msg.team2 ?? msg.t) @@ -221,7 +246,7 @@ export default function TelemetrySocket() { } }, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, hideOverlay, mySteamId]) - // ----- banner logic (connected + disconnected variants) with roster fallback + // ----- banner logic const myId = mySteamId ? String(mySteamId) : null const roster = rosterSet instanceof Set && rosterSet.size > 0 @@ -231,12 +256,8 @@ export default function TelemetrySocket() { const iAmExpected = !!myId && roster.has(myId) const iAmOnline = !!myId && telemetrySet.has(myId) - const intersectCount = (() => { - if (roster.size === 0) return 0 - let n = 0 - for (const sid of roster) if (telemetrySet.has(sid)) n++ - return n - })() + let intersectCount = 0 + for (const sid of roster) if (telemetrySet.has(sid)) intersectCount++ const totalExpected = roster.size const connectUri = @@ -245,10 +266,7 @@ export default function TelemetrySocket() { process.env.NEXT_PUBLIC_CS2_CONNECT_URI || 'steam://rungameid/730//+retry' - // Fallback-Label aus URI, falls kein Servername vom WS kam - const fallbackServerLabel = parseServerLabel(connectUri) - const effectiveServerLabel = (serverName && serverName.trim()) || fallbackServerLabel - + 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) @@ -258,44 +276,28 @@ export default function TelemetrySocket() { } const handleDisconnect = async () => { - // Auto-Reconnect stoppen - aliveRef.current = false; - if (retryRef.current) { - window.clearTimeout(retryRef.current); - retryRef.current = null; - } - - // WebSocket zu + 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; + wsRef.current = null + setTelemetrySet(new Set()) - // ❗ NICHT: setPhase('unknown'), setMapKeyForUi(null), setServerName(null) - // Nur "Online"-Set leeren, damit variant = 'disconnected' - setTelemetrySet(new Set()); - // Score darf bleiben; falls du willst, kannst du ihn optional leeren: - // setScore({ a: null, b: null }); - - // Kick an Server schicken try { - const who = myName || mySteamId; + const who = myName || mySteamId if (who) { - const cmd = `kick ${quoteArg(String(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 }), - }); + }) } - } catch (err) { - if (process.env.NODE_ENV !== 'production') { - console.warn('[TelemetrySocket] kick command failed:', err); - } - } - }; + } catch {} + } const variant: 'connected' | 'disconnected' = iAmOnline ? 'connected' : 'disconnected' - const visible = iAmExpected + const visible = iAmExpected // 👈 Banner nur für zugeordnete Spieler const zIndex = iAmOnline ? 9998 : 9999 const bannerEl = (