updated
This commit is contained in:
parent
8f88be26ce
commit
c692cefb22
@ -348,26 +348,10 @@ function uniq<T>(arr: T[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collectParticipants(match: any): string[] {
|
function collectParticipants(match: any): string[] {
|
||||||
const fromMatchPlayers =
|
const a = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
|
||||||
(match.players ?? [])
|
const b = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
|
||||||
.map((mp: any) => mp?.user?.steamId)
|
// ❗ keine Leader/activePlayers/match.players mehr – NUR echte Teamspieler
|
||||||
.filter(Boolean)
|
return Array.from(new Set<string>([...a, ...b]))
|
||||||
|
|
||||||
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<string>([
|
|
||||||
...fromMatchPlayers,
|
|
||||||
...fromTeamUsersA, ...fromTeamUsersB,
|
|
||||||
...fromActiveA, ...fromActiveB,
|
|
||||||
...leaderA, ...leaderB,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistMatchPlayers(match: any) {
|
async function persistMatchPlayers(match: any) {
|
||||||
@ -542,27 +526,11 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
|
|||||||
data: { exportedAt: new Date() },
|
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) {
|
} catch (err) {
|
||||||
console.error('[mapvote] Export fehlgeschlagen:', err)
|
console.error('[mapvote] Export fehlgeschlagen:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* ---------- kleine Helfer für match-ready Payload ---------- */
|
/* ---------- kleine Helfer für match-ready Payload ---------- */
|
||||||
|
|
||||||
function deriveChosenSteps(vote: any) {
|
function deriveChosenSteps(vote: any) {
|
||||||
@ -649,8 +617,19 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
|
|||||||
|
|
||||||
if (!current) {
|
if (!current) {
|
||||||
// Kein Schritt mehr -> Vote abschließen
|
// Kein Schritt mehr -> Vote abschließen
|
||||||
await prisma.mapVote.update({ where: { id: vote.id }, data: { locked: true } })
|
await prisma.mapVote.update({
|
||||||
const updated = await prisma.mapVote.findUnique({ where: { id: vote.id }, include: { steps: true } })
|
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({
|
await sendServerSSEMessage({
|
||||||
type: 'map-vote-updated',
|
type: 'map-vote-updated',
|
||||||
@ -700,7 +679,13 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st
|
|||||||
})
|
})
|
||||||
await tx.mapVote.update({
|
await tx.mapVote.update({
|
||||||
where: { id: vote.id },
|
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({
|
await tx.mapVote.update({
|
||||||
where: { id: after.id },
|
where: { id: after.id },
|
||||||
data : { currentIdx: idx, locked },
|
data : {
|
||||||
|
currentIdx: idx,
|
||||||
|
locked,
|
||||||
|
// ➜ Nur wenn jetzt abgeschlossen: Admin-Edit beenden
|
||||||
|
...(locked ? { adminEditingBy: null, adminEditingSince: null } : {}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -50,22 +50,10 @@ function voteOpensAt(base: Date, leadMinutes: number) {
|
|||||||
return new Date(base.getTime() - leadMinutes * 60_000)
|
return new Date(base.getTime() - leadMinutes * 60_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Steps-Builder für BO1/BO3/BO5
|
// Steps-Builder für BO3/BO5
|
||||||
function buildSteps(bestOf: 1 | 3 | 5, firstTeamId: string | null, secondTeamId: string | null) {
|
function buildSteps(bestOf: 3 | 5, firstTeamId: string | null, secondTeamId: string | null) {
|
||||||
const A = firstTeamId
|
const A = firstTeamId
|
||||||
const B = secondTeamId
|
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) {
|
if (bestOf === 3) {
|
||||||
// 2x Ban, 2x Pick, 2x Ban, Decider
|
// 2x Ban, 2x Pick, 2x Ban, Decider
|
||||||
return [
|
return [
|
||||||
@ -108,7 +96,6 @@ export async function PUT(
|
|||||||
teamAId,
|
teamAId,
|
||||||
teamBId,
|
teamBId,
|
||||||
matchDate,
|
matchDate,
|
||||||
map,
|
|
||||||
voteLeadMinutes, // optional
|
voteLeadMinutes, // optional
|
||||||
demoDate,
|
demoDate,
|
||||||
bestOf: bestOfRaw, // <- NEU
|
bestOf: bestOfRaw, // <- NEU
|
||||||
@ -116,7 +103,7 @@ export async function PUT(
|
|||||||
|
|
||||||
// BestOf validieren (nur 1/3/5 zulassen)
|
// BestOf validieren (nur 1/3/5 zulassen)
|
||||||
const bestOf =
|
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 {
|
try {
|
||||||
const match = await prisma.match.findUnique({
|
const match = await prisma.match.findUnique({
|
||||||
@ -140,7 +127,6 @@ export async function PUT(
|
|||||||
const updateData: any = {}
|
const updateData: any = {}
|
||||||
if (typeof title !== 'undefined') updateData.title = title
|
if (typeof title !== 'undefined') updateData.title = title
|
||||||
if (typeof matchType === 'string') updateData.matchType = matchType
|
if (typeof matchType === 'string') updateData.matchType = matchType
|
||||||
if (typeof map !== 'undefined') updateData.map = map
|
|
||||||
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
|
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
|
||||||
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
|
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
|
||||||
if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten
|
if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten
|
||||||
@ -318,7 +304,6 @@ export async function PUT(
|
|||||||
teamBId: updated.teamBId,
|
teamBId: updated.teamBId,
|
||||||
matchDate: updated.matchDate,
|
matchDate: updated.matchDate,
|
||||||
demoDate: updated.demoDate,
|
demoDate: updated.demoDate,
|
||||||
map: updated.map,
|
|
||||||
bestOf: updated.bestOf,
|
bestOf: updated.bestOf,
|
||||||
mapVote: updated.mapVote,
|
mapVote: updated.mapVote,
|
||||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
@ -327,3 +312,62 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Failed to update match meta' }, { status: 500 })
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/app/api/matches/current/route.ts
Normal file
65
src/app/api/matches/current/route.ts
Normal file
@ -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<T>(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' } }
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
// app/components/EditMatchMetaModal.tsx
|
// /src/app/components/EditMatchMetaModal.tsx
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import Modal from '@/app/components/Modal'
|
import Modal from '@/app/components/Modal'
|
||||||
import Alert from '@/app/components/Alert'
|
import Alert from '@/app/components/Alert'
|
||||||
import Select from '@/app/components/Select'
|
import Select from '@/app/components/Select'
|
||||||
|
import LoadingSpinner from '@/app/components/LoadingSpinner' // ⬅️ NEU
|
||||||
|
|
||||||
type TeamOption = { id: string; name: string; logo?: string | null }
|
type TeamOption = { id: string; name: string; logo?: string | null }
|
||||||
|
|
||||||
@ -18,10 +19,10 @@ type Props = {
|
|||||||
defaultTeamAName?: string | null
|
defaultTeamAName?: string | null
|
||||||
defaultTeamBName?: string | null
|
defaultTeamBName?: string | null
|
||||||
defaultDateISO?: 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
|
defaultVoteLeadMinutes?: number
|
||||||
onSaved?: () => void
|
onSaved?: () => void
|
||||||
defaultBestOf?: 1 | 3 | 5
|
defaultBestOf?: 3 | 5
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditMatchMetaModal({
|
export default function EditMatchMetaModal({
|
||||||
@ -34,108 +35,159 @@ export default function EditMatchMetaModal({
|
|||||||
defaultTeamAName,
|
defaultTeamAName,
|
||||||
defaultTeamBName,
|
defaultTeamBName,
|
||||||
defaultDateISO,
|
defaultDateISO,
|
||||||
// defaultMap, // nicht mehr genutzt
|
// defaultMap,
|
||||||
defaultVoteLeadMinutes = 60,
|
defaultVoteLeadMinutes = 60,
|
||||||
onSaved,
|
onSaved,
|
||||||
defaultBestOf = 3,
|
defaultBestOf = 3,
|
||||||
}: Props) {
|
}: 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 [title, setTitle] = useState(defaultTitle ?? '')
|
||||||
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
|
||||||
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
|
||||||
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
|
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
|
||||||
|
const [date, setDate] = useState<string>(toDatetimeLocal(defaultDateISO))
|
||||||
const [date, setDate] = useState<string>(() => {
|
const [bestOf, setBestOf] = useState<3 | 5>(normalizeBestOf(defaultBestOf))
|
||||||
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 [teams, setTeams] = useState<TeamOption[]>([])
|
const [teams, setTeams] = useState<TeamOption[]>([])
|
||||||
const [loadingTeams, setLoadingTeams] = useState(false)
|
const [loadingTeams, setLoadingTeams] = useState(false)
|
||||||
|
|
||||||
|
const [loadingMeta, setLoadingMeta] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
if (!show) return
|
if (!show) return
|
||||||
|
if (teamsFetchedRef.current) return
|
||||||
|
|
||||||
|
let alive = true
|
||||||
setLoadingTeams(true)
|
setLoadingTeams(true)
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/teams', { cache: 'no-store' })
|
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||||||
const data = res.ok ? await res.json() : []
|
const data = res.ok ? await res.json() : []
|
||||||
|
if (!alive) return
|
||||||
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? [])
|
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? [])
|
||||||
setTeams((list ?? []).filter((t: any) => t?.id && t?.name))
|
setTeams((list ?? []).filter((t: any) => t?.id && t?.name))
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
|
if (!alive) return
|
||||||
console.error('[EditMatchMetaModal] load teams failed:', e)
|
console.error('[EditMatchMetaModal] load teams failed:', e)
|
||||||
setTeams([])
|
setTeams([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingTeams(false)
|
if (alive) {
|
||||||
|
setLoadingTeams(false)
|
||||||
|
teamsFetchedRef.current = true // Flag erst nach erfolgreichem Lauf
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
teamsFetchedRef.current = false
|
||||||
|
}
|
||||||
}, [show])
|
}, [show])
|
||||||
|
|
||||||
// Defaults beim Öffnen (einmal)
|
/* ───────── Meta neu laden beim Öffnen ───────── */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) { openedOnceRef.current = false; return }
|
if (!show) return
|
||||||
if (openedOnceRef.current) return
|
if (metaFetchedRef.current) return
|
||||||
openedOnceRef.current = true
|
|
||||||
|
|
||||||
setTitle(defaultTitle ?? '')
|
let alive = true
|
||||||
setTeamAId(defaultTeamAId ?? '')
|
setLoadingMeta(true)
|
||||||
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)
|
|
||||||
setError(null)
|
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(
|
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]
|
[teams, teamBId]
|
||||||
)
|
)
|
||||||
const teamOptionsB = useMemo(
|
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]
|
[teams, teamAId]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hinweis-Flag: Best Of geändert?
|
/* ───────── Hinweis-Flag nur vs. /meta ───────── */
|
||||||
const defaultBestOfNormalized = (defaultBestOf ?? 3) as 1 | 3 | 5
|
const showBoChangedHint = metaBestOf !== null && bestOf !== metaBestOf
|
||||||
const bestOfChanged = bestOf !== defaultBestOfNormalized
|
|
||||||
|
|
||||||
// Validation
|
/* ───────── Validation ───────── */
|
||||||
const canSave = useMemo(() => {
|
const canSave = useMemo(() => {
|
||||||
if (saving) return false
|
if (saving || loadingMeta) return false
|
||||||
if (!date) return false
|
if (!date) return false
|
||||||
if (teamAId && teamBId && teamAId === teamBId) return false
|
if (teamAId && teamBId && teamAId === teamBId) return false
|
||||||
return true
|
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 () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@ -146,7 +198,7 @@ export default function EditMatchMetaModal({
|
|||||||
teamBId: teamBId || null,
|
teamBId: teamBId || null,
|
||||||
matchDate: date ? new Date(date).toISOString() : null,
|
matchDate: date ? new Date(date).toISOString() : null,
|
||||||
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
|
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
|
||||||
bestOf, // <- wichtig
|
bestOf,
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/matches/${matchId}/meta`, {
|
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 teamAPlaceholder = loadingTeams ? 'Teams laden …' : defaultTeamAName || 'Team A wählen …'
|
||||||
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B 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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@ -180,109 +235,125 @@ export default function EditMatchMetaModal({
|
|||||||
show={show}
|
show={show}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
closeButtonTitle={saved ? '✓ Gespeichert' : saving ? 'Speichern …' : 'Speichern'}
|
closeButtonTitle={
|
||||||
|
saved ? '✓ Gespeichert' : saving ? 'Speichern …' : loadingMeta ? 'Lädt …' : 'Speichern'
|
||||||
|
}
|
||||||
closeButtonColor={saved ? 'green' : 'blue'}
|
closeButtonColor={saved ? 'green' : 'blue'}
|
||||||
disableSave={!canSave}
|
disableSave={!canSave}
|
||||||
maxWidth="sm:max-w-2xl"
|
maxWidth="sm:max-w-2xl"
|
||||||
>
|
>
|
||||||
{error && (
|
{error && !loadingMeta && (
|
||||||
<Alert type="soft" color="danger" className="mb-3">
|
<Alert type="soft" color="danger" className="mb-3">
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
{showOnlySpinner ? (
|
||||||
{/* Titel */}
|
<div className="py-12 flex items-center justify-center">
|
||||||
<div className="col-span-2">
|
<LoadingSpinner />
|
||||||
<label className="block text-sm font-medium mb-1">Titel</label>
|
|
||||||
<input
|
|
||||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
|
||||||
value={title}
|
|
||||||
onChange={e => setTitle(e.target.value)}
|
|
||||||
placeholder="z.B. Scrim vs. XYZ"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{/* Team A */}
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
{/* Titel */}
|
||||||
<label className="block text-sm font-medium mb-1">Team A</label>
|
<div className="col-span-2">
|
||||||
<Select
|
<label className="block text-sm font-medium mb-1">Titel</label>
|
||||||
options={teamOptionsA}
|
<input
|
||||||
value={teamAId}
|
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||||
onChange={setTeamAId}
|
value={title}
|
||||||
placeholder={teamAPlaceholder}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
dropDirection="auto"
|
placeholder="z.B. Scrim vs. XYZ"
|
||||||
/>
|
disabled={loadingMeta}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{/* Team B */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Team B</label>
|
|
||||||
<Select
|
|
||||||
options={teamOptionsB}
|
|
||||||
value={teamBId}
|
|
||||||
onChange={setTeamBId}
|
|
||||||
placeholder={teamBPlaceholder}
|
|
||||||
dropDirection="auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Datum/Uhrzeit */}
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-1">Datum & Uhrzeit</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
|
||||||
value={date}
|
|
||||||
onChange={e => setDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vote-Lead */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Map-Vote lead (Minuten)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
|
||||||
value={voteLead}
|
|
||||||
onChange={e => setVoteLead(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nur noch Best Of */}
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-1">Modus (Best of)</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{[1, 3, 5].map(bo => (
|
|
||||||
<button
|
|
||||||
key={bo}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setBestOf(bo as 1|3|5)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm border
|
|
||||||
${bestOf === bo
|
|
||||||
? 'bg-blue-600 text-white border-blue-600'
|
|
||||||
: 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
|
|
||||||
>
|
|
||||||
BO{bo}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{bestOfChanged && (
|
{/* Team A */}
|
||||||
<Alert type="soft" color="warning" className="mt-2">
|
<div>
|
||||||
Du hast den Modus von <b>BO{defaultBestOfNormalized}</b> auf <b>BO{bestOf}</b> geändert.
|
<label className="block text-sm font-medium mb-1">Team A</label>
|
||||||
Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden verworfen).
|
<Select
|
||||||
</Alert>
|
options={teamOptionsA}
|
||||||
)}
|
value={teamAId}
|
||||||
|
onChange={setTeamAId}
|
||||||
|
placeholder={teamAPlaceholder}
|
||||||
|
dropDirection="auto"
|
||||||
|
disabled={loadingMeta}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Team B */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Team B</label>
|
||||||
|
<Select
|
||||||
|
options={teamOptionsB}
|
||||||
|
value={teamBId}
|
||||||
|
onChange={setTeamBId}
|
||||||
|
placeholder={teamBPlaceholder}
|
||||||
|
dropDirection="auto"
|
||||||
|
disabled={loadingMeta}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datum/Uhrzeit */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Datum & Uhrzeit</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
disabled={loadingMeta}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Wird als ISO gespeichert ({date ? new Date(date).toISOString() : '—'}).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vote-Lead */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Map-Vote lead (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="w-full rounded-md border px-3 py-2 bg-white/70 dark:bg-neutral-900/50"
|
||||||
|
value={voteLead}
|
||||||
|
onChange={(e) => setVoteLead(Number(e.target.value))}
|
||||||
|
disabled={loadingMeta}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Best Of */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">Modus (Best of)</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[3, 5].map((bo) => (
|
||||||
|
<button
|
||||||
|
key={bo}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBestOf(bo as 3 | 5)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm border ${
|
||||||
|
bestOf === bo
|
||||||
|
? 'bg-blue-600 text-white border-blue-600'
|
||||||
|
: 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'
|
||||||
|
}`}
|
||||||
|
disabled={loadingMeta}
|
||||||
|
>
|
||||||
|
BO{bo}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{metaBestOf !== null && bestOf !== metaBestOf && (
|
||||||
|
<Alert type="soft" color="warning" className="mt-2">
|
||||||
|
Du hast den Modus von <b>BO{metaBestOf}</b> auf <b>BO{bestOf}</b> geändert.
|
||||||
|
Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden
|
||||||
|
verworfen).
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,6 +134,9 @@ export default function MapVoteBanner({
|
|||||||
const isLocked = !!state?.locked
|
const isLocked = !!state?.locked
|
||||||
const isVotingOpen = isOpen && !isLocked
|
const isVotingOpen = isOpen && !isLocked
|
||||||
|
|
||||||
|
const isEnded = !!state?.locked
|
||||||
|
const isLive = isOpen && !isEnded
|
||||||
|
|
||||||
const current = state?.steps?.[state?.currentIndex ?? 0]
|
const current = state?.steps?.[state?.currentIndex ?? 0]
|
||||||
const whoIsUp = current?.teamId
|
const whoIsUp = current?.teamId
|
||||||
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
|
? (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`)
|
const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`)
|
||||||
|
|
||||||
// Farblogik: locked → grün, offen → gelb, noch geschlossen → neutral
|
// Vorher: locked -> grün, live -> gelb
|
||||||
const ringClass = isLocked
|
// Neu: live -> grün, locked -> grau
|
||||||
? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg'
|
const ringClass = isLive
|
||||||
: isVotingOpen
|
? 'ring-1 ring-green-500/20 hover:ring-green-500/35 hover:shadow-lg'
|
||||||
? 'ring-1 ring-yellow-500/20 hover:ring-yellow-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'
|
: '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'
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200'
|
||||||
: isVotingOpen
|
: isEnded
|
||||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100'
|
? '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'
|
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700/40 dark:text-neutral-200'
|
||||||
|
|
||||||
const gradientClass = isLocked
|
const gradientClass = isLive
|
||||||
? 'mapVoteGradient--green'
|
? 'mapVoteGradient--green'
|
||||||
: isVotingOpen
|
: 'mapVoteGradient--none' // beendet und noch-nicht-offen: kein Effekt
|
||||||
? 'mapVoteGradient--yellow'
|
|
||||||
: 'mapVoteGradient--none'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -199,9 +202,9 @@ export default function MapVoteBanner({
|
|||||||
<div className="font-medium text-gray-900 dark:text-neutral-100">Map-Vote</div>
|
<div className="font-medium text-gray-900 dark:text-neutral-100">Map-Vote</div>
|
||||||
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
|
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
|
||||||
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
|
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
|
||||||
{state?.locked
|
{isEnded
|
||||||
? ' • Auswahl fixiert'
|
? ' • Auswahl fixiert'
|
||||||
: isVotingOpen
|
: isLive
|
||||||
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
|
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
|
||||||
: ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`}
|
: ` • startet ${formatLead(leadMinutes)} vor Matchbeginn`}
|
||||||
</div>
|
</div>
|
||||||
@ -210,19 +213,17 @@ export default function MapVoteBanner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{state?.locked ? (
|
{isEnded ? (
|
||||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200">
|
||||||
Voting abgeschlossen
|
Voting abgeschlossen
|
||||||
</span>
|
</span>
|
||||||
) : isVotingOpen ? (
|
) : isLive ? (
|
||||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100">
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-100">
|
||||||
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
|
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-neutral-100 text-neutral-800 dark:bg-neutral-700/40 dark:text-neutral-200"
|
||||||
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100"
|
suppressHydrationWarning>
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
Öffnet in {mounted ? formatCountdown(msToOpen) : '–:–:–'}
|
Öffnet in {mounted ? formatCountdown(msToOpen) : '–:–:–'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -880,6 +880,15 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
? chosenSteps.slice(0, idx).filter(s => s.action === 'pick').length + 1
|
? chosenSteps.slice(0, idx).filter(s => s.action === 'pick').length + 1
|
||||||
: null
|
: 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 pickedByLeft = (action === 'pick' || action === 'decider') && pickTeamId === leftTeamId
|
||||||
const pickedByRight = (action === 'pick' || action === 'decider') && pickTeamId === rightTeamId
|
const pickedByRight = (action === 'pick' || action === 'decider') && pickTeamId === rightTeamId
|
||||||
const cornerLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null
|
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) */}
|
{/* Nummer oben links (für Pick UND Decider) */}
|
||||||
{typeof pickNumber === 'number' && (
|
{typeof displayNumber === 'number' && (
|
||||||
<span
|
<span
|
||||||
className="absolute left-2 top-2 z-30 w-6 h-6 rounded-full
|
className="absolute left-2 top-2 z-30 w-6 h-6 rounded-full
|
||||||
bg-white/95 text-neutral-900 text-xs md:text-sm font-bold
|
bg-white/95 text-neutral-900 text-xs md:text-sm font-bold
|
||||||
flex items-center justify-center shadow"
|
flex items-center justify-center shadow"
|
||||||
title={`Pick ${pickNumber}`}
|
title={
|
||||||
|
action === 'pick'
|
||||||
|
? `Pick ${displayNumber}`
|
||||||
|
: action === 'decider'
|
||||||
|
? `Decider (Map ${displayNumber})`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{pickNumber}
|
{displayNumber}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -562,9 +562,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
|
|||||||
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
|
||||||
defaultMap={match.map ?? null}
|
defaultMap={match.map ?? null}
|
||||||
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
|
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
|
||||||
// ⬇️ neu:
|
defaultBestOf={Number(match.bestOf) === 5 ? 5 : 3}
|
||||||
defaultBestOf={(match.bestOf as 1 | 3 | 5) ?? 3}
|
|
||||||
defaultSeries={extractSeriesMaps(match)} // Array mit map-Keys (kann '' enthalten)
|
|
||||||
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { useSSEStore } from '@/app/lib/useSSEStore'
|
|||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
import { MAP_OPTIONS } from '../lib/mapOptions'
|
import { MAP_OPTIONS } from '../lib/mapOptions'
|
||||||
import type { MapOption } from '../lib/mapOptions'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@ -42,14 +41,23 @@ export default function MatchReadyOverlay({
|
|||||||
connectHref
|
connectHref
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const mySteamId = session?.user?.steamId
|
const mySteamId = session?.user?.steamId ? String(session.user.steamId) : null
|
||||||
const { lastEvent } = useSSEStore()
|
const { lastEvent } = useSSEStore()
|
||||||
|
|
||||||
|
// --- Team-Guard: nur Team A/B
|
||||||
|
const [allowedIds, setAllowedIds] = useState<string[] | null>(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 ENV_CONNECT_HREF = process.env.NEXT_PUBLIC_CONNECT_HREF
|
||||||
const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000'
|
const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000'
|
||||||
const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF
|
const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF
|
||||||
|
|
||||||
|
// Timer
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
const [startedAt] = useState(() => Date.now())
|
const [startedAt] = useState(() => Date.now())
|
||||||
const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt])
|
const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt])
|
||||||
@ -59,30 +67,29 @@ export default function MatchReadyOverlay({
|
|||||||
// UI-States
|
// UI-States
|
||||||
const [accepted, setAccepted] = useState(false)
|
const [accepted, setAccepted] = useState(false)
|
||||||
const [finished, setFinished] = 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 [connecting, setConnecting] = useState(false)
|
||||||
const isVisible = open || accepted || showWaitHint
|
|
||||||
const [showConnectHelp, setShowConnectHelp] = useState(false)
|
const [showConnectHelp, setShowConnectHelp] = useState(false)
|
||||||
const [showBackdrop, setShowBackdrop] = useState(false)
|
const [showBackdrop, setShowBackdrop] = useState(false)
|
||||||
const [showContent, setShowContent] = 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 }
|
type Participant = { steamId: string; name: string; avatar: string; team: 'A' | 'B' | null }
|
||||||
const [participants, setParticipants] = useState<Participant[]>([])
|
const [participants, setParticipants] = useState<Participant[]>([])
|
||||||
const [readyMap, setReadyMap] = useState<Record<string, string>>({})
|
const [readyMap, setReadyMap] = useState<Record<string, string>>({})
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [countReady, setCountReady] = useState(0)
|
const [countReady, setCountReady] = useState(0)
|
||||||
|
|
||||||
// Presence-Map (SSE)
|
// Presence (SSE)
|
||||||
const [statusMap, setStatusMap] = useState<Record<string, Presence>>({})
|
const [statusMap, setStatusMap] = useState<Record<string, Presence>>({})
|
||||||
|
|
||||||
const prevCountReadyRef = useRef<number>(0)
|
|
||||||
const ignoreNextIncreaseRef = useRef(false)
|
|
||||||
|
|
||||||
// ----- AUDIO -----
|
// ----- AUDIO -----
|
||||||
const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const beepsRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const audioStartedRef = useRef(false)
|
const audioStartedRef = useRef(false)
|
||||||
|
|
||||||
const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } }
|
const stopBeeps = () => { if (beepsRef.current) { clearInterval(beepsRef.current); beepsRef.current = null } }
|
||||||
|
|
||||||
const ensureAudioUnlocked = async () => {
|
const ensureAudioUnlocked = async () => {
|
||||||
@ -127,33 +134,35 @@ export default function MatchReadyOverlay({
|
|||||||
}
|
}
|
||||||
try { onTimeout?.() } catch {}
|
try { onTimeout?.() } catch {}
|
||||||
}
|
}
|
||||||
// mini delay für UI Feedback
|
|
||||||
setTimeout(doConnect, 200)
|
setTimeout(doConnect, 200)
|
||||||
}, [finished, effectiveConnectHref, onTimeout])
|
}, [finished, effectiveConnectHref, onTimeout])
|
||||||
|
|
||||||
// NUR nach Acceptance laden/aktualisieren
|
// Ready-API nur nach Accept
|
||||||
const loadReady = useCallback(async () => {
|
const loadReady = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' })
|
const r = await fetch(`/api/matches/${matchId}/ready`, { cache: 'no-store' })
|
||||||
if (!r.ok) return
|
if (!r.ok) return
|
||||||
const j = await r.json()
|
const j = await r.json()
|
||||||
setParticipants(j.participants ?? [])
|
const parts: Participant[] = j.participants ?? []
|
||||||
|
setParticipants(parts)
|
||||||
setReadyMap(j.ready ?? {})
|
setReadyMap(j.ready ?? {})
|
||||||
setTotal(j.total ?? 0)
|
setTotal(j.total ?? 0)
|
||||||
setCountReady(j.countReady ?? 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 {}
|
} catch {}
|
||||||
}, [matchId])
|
}, [matchId])
|
||||||
|
|
||||||
// ---- Accept-Handling ----
|
// Accept
|
||||||
const postingRef = useRef(false)
|
const postingRef = useRef(false)
|
||||||
|
|
||||||
const onAcceptClick = async () => {
|
const onAcceptClick = async () => {
|
||||||
if (postingRef.current) return
|
if (postingRef.current) return
|
||||||
postingRef.current = true
|
postingRef.current = true
|
||||||
try {
|
try {
|
||||||
stopBeeps()
|
stopBeeps()
|
||||||
playMenuAccept()
|
playMenuAccept()
|
||||||
|
|
||||||
const res = await fetch(`/api/matches/${matchId}/ready`, {
|
const res = await fetch(`/api/matches/${matchId}/ready`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Ready-Accept': '1' },
|
headers: { 'Content-Type': 'application/json', 'X-Ready-Accept': '1' },
|
||||||
@ -161,7 +170,6 @@ export default function MatchReadyOverlay({
|
|||||||
body: JSON.stringify({ intent: 'accept' }),
|
body: JSON.stringify({ intent: 'accept' }),
|
||||||
})
|
})
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
setAccepted(true)
|
setAccepted(true)
|
||||||
try { await onAccept() } catch {}
|
try { await onAccept() } catch {}
|
||||||
await loadReady()
|
await loadReady()
|
||||||
@ -170,8 +178,7 @@ export default function MatchReadyOverlay({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// „Es lädt nicht?“ nach 30s
|
||||||
// ⬇️ NEU: nach 30s „Es lädt nicht?“ anzeigen
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let id: number | null = null
|
let id: number | null = null
|
||||||
if (connecting) {
|
if (connecting) {
|
||||||
@ -181,17 +188,15 @@ export default function MatchReadyOverlay({
|
|||||||
return () => { if (id) window.clearTimeout(id) }
|
return () => { if (id) window.clearTimeout(id) }
|
||||||
}, [connecting])
|
}, [connecting])
|
||||||
|
|
||||||
// Backdrop zuerst faden, dann Content
|
// Backdrop → Content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) { setShowBackdrop(false); setShowContent(false); return }
|
if (!shouldRender) { setShowBackdrop(false); setShowContent(false); return }
|
||||||
setShowBackdrop(true)
|
setShowBackdrop(true)
|
||||||
const id = setTimeout(() => setShowContent(true), 300) // vorher: 2000
|
const id = setTimeout(() => setShowContent(true), 300)
|
||||||
return () => clearTimeout(id)
|
return () => clearTimeout(id)
|
||||||
}, [isVisible])
|
}, [shouldRender])
|
||||||
|
|
||||||
if (!isVisible) return null
|
// Nach Accept kurzer Refresh
|
||||||
|
|
||||||
// Nach Accept ein kurzer Refresh
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accepted) return
|
if (!accepted) return
|
||||||
const id = setTimeout(loadReady, 250)
|
const id = setTimeout(loadReady, 250)
|
||||||
@ -199,12 +204,19 @@ export default function MatchReadyOverlay({
|
|||||||
}, [accepted, loadReady])
|
}, [accepted, loadReady])
|
||||||
|
|
||||||
// SSE
|
// SSE
|
||||||
const { lastEvent: le } = useSSEStore()
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastEvent) return
|
if (!lastEvent) return
|
||||||
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
|
||||||
const payload = (lastEvent as any).payload?.payload ?? (lastEvent as any).payload ?? lastEvent
|
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 (type === 'ready-updated' && payload?.matchId === matchId) {
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
const otherSteamId = payload?.steamId as string | undefined
|
const otherSteamId = payload?.steamId as string | undefined
|
||||||
@ -223,15 +235,15 @@ export default function MatchReadyOverlay({
|
|||||||
}
|
}
|
||||||
}, [accepted, lastEvent, matchId, mySteamId, loadReady])
|
}, [accepted, lastEvent, matchId, mySteamId, loadReady])
|
||||||
|
|
||||||
// ----- simple mount animation flags -----
|
// Mount-Animation
|
||||||
const [fadeIn, setFadeIn] = useState(false)
|
const [fadeIn, setFadeIn] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) { setFadeIn(false); return }
|
if (!shouldRender) { setFadeIn(false); return }
|
||||||
const id = requestAnimationFrame(() => setFadeIn(true))
|
const id = requestAnimationFrame(() => setFadeIn(true))
|
||||||
return () => cancelAnimationFrame(id)
|
return () => cancelAnimationFrame(id)
|
||||||
}, [isVisible])
|
}, [shouldRender])
|
||||||
|
|
||||||
// ----- motion layer (video/gif) -----
|
// Motion-Layer
|
||||||
const prefersReducedMotion = useMemo(
|
const prefersReducedMotion = useMemo(
|
||||||
() => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
() => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
|
||||||
[]
|
[]
|
||||||
@ -240,7 +252,7 @@ export default function MatchReadyOverlay({
|
|||||||
const [useGif, setUseGif] = useState<boolean>(() => !!forceGif || !!prefersReducedMotion)
|
const [useGif, setUseGif] = useState<boolean>(() => !!forceGif || !!prefersReducedMotion)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return
|
if (!shouldRender) return
|
||||||
if (forceGif || prefersReducedMotion) { setUseGif(true); return }
|
if (forceGif || prefersReducedMotion) { setUseGif(true); return }
|
||||||
const tryPlay = async () => {
|
const tryPlay = async () => {
|
||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
@ -259,22 +271,21 @@ export default function MatchReadyOverlay({
|
|||||||
}
|
}
|
||||||
const id = setTimeout(tryPlay, 0)
|
const id = setTimeout(tryPlay, 0)
|
||||||
return () => clearTimeout(id)
|
return () => clearTimeout(id)
|
||||||
}, [isVisible, forceGif, prefersReducedMotion])
|
}, [shouldRender, forceGif, prefersReducedMotion])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) return
|
if (shouldRender) return
|
||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
if (v) {
|
if (v) {
|
||||||
try { v.pause() } catch {}
|
try { v.pause() } catch {}
|
||||||
v.removeAttribute('src')
|
v.removeAttribute('src')
|
||||||
while (v.firstChild) v.removeChild(v.firstChild)
|
while (v.firstChild) v.removeChild(v.firstChild)
|
||||||
}
|
}
|
||||||
}, [isVisible])
|
}, [shouldRender])
|
||||||
|
|
||||||
// ----- AUDIO: Beeps starten/stoppen -----
|
// AUDIO: Beeps starten/stoppen
|
||||||
// Beeps erst starten, wenn der Content sichtbar ist
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showContent) { // vorher: if (!isVisible)
|
if (!showContent) {
|
||||||
stopBeeps()
|
stopBeeps()
|
||||||
audioStartedRef.current = false
|
audioStartedRef.current = false
|
||||||
return
|
return
|
||||||
@ -300,53 +311,53 @@ export default function MatchReadyOverlay({
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return () => { cleanup(); stopBeeps() }
|
return () => { cleanup(); stopBeeps() }
|
||||||
}, [showContent]) // vorher: [isVisible]
|
}, [showContent])
|
||||||
|
|
||||||
// ⏩ Sofort verbinden, wenn alle bereit sind
|
// Auto-Connect wenn alle bereit
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return
|
if (!shouldRender) return
|
||||||
if (total > 0 && countReady >= total && !finished) {
|
if (total > 0 && countReady >= total && !finished) {
|
||||||
startConnectingNow()
|
startConnectingNow()
|
||||||
}
|
}
|
||||||
}, [isVisible, total, countReady, finished, startConnectingNow])
|
}, [shouldRender, total, countReady, finished, startConnectingNow])
|
||||||
|
|
||||||
// ----- countdown / timeout -----
|
// Countdown
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return
|
if (!shouldRender) return
|
||||||
const step = () => {
|
const step = () => {
|
||||||
const t = Date.now()
|
const t = Date.now()
|
||||||
setNow(t)
|
setNow(t)
|
||||||
if (effectiveDeadline - t <= 0 && !finished) {
|
if (effectiveDeadline - t <= 0 && !finished) {
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
startConnectingNow()
|
startConnectingNow()
|
||||||
} else {
|
} else {
|
||||||
stopBeeps()
|
stopBeeps()
|
||||||
setFinished(true)
|
setFinished(true)
|
||||||
setShowWaitHint(true)
|
setShowWaitHint(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
rafRef.current = requestAnimationFrame(step)
|
rafRef.current = requestAnimationFrame(step)
|
||||||
}
|
}
|
||||||
rafRef.current = requestAnimationFrame(step)
|
rafRef.current = requestAnimationFrame(step)
|
||||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
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 mapIconUrl = useMemo(() => {
|
||||||
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase()
|
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase()
|
||||||
const keyFromBg = /\/maps\/([^/]+)\//.exec(mapBg ?? '')?.[1]
|
const keyFromBg = /\/maps\/([^/]+)\//.exec(mapBg ?? '')?.[1]
|
||||||
|
|
||||||
const byKey = keyFromBg ? MAP_OPTIONS.find(o => o.key === keyFromBg) : undefined
|
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 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 byImage = mapBg ? MAP_OPTIONS.find(o => o.images.includes(mapBg)) : undefined
|
||||||
|
|
||||||
const opt = byKey ?? byLabel ?? byImage
|
const opt = byKey ?? byLabel ?? byImage
|
||||||
return opt?.icon ?? '/assets/img/mapicons/map_icon_lobby_mapveto.svg'
|
return opt?.icon ?? '/assets/img/mapicons/map_icon_lobby_mapveto.svg'
|
||||||
}, [mapBg, mapLabel])
|
}, [mapBg, mapLabel])
|
||||||
|
|
||||||
// --- UI Helpers ---
|
// ---------- RENDER ----------
|
||||||
|
if (!shouldRender) return null
|
||||||
|
|
||||||
const ReadyRow = () => (
|
const ReadyRow = () => (
|
||||||
<div className="absolute left-0 right-0 bottom-0 px-4 py-3">
|
<div className="absolute left-0 right-0 bottom-0 px-4 py-3">
|
||||||
<div className="flex items-center gap-2 mb-1 justify-center">
|
<div className="flex items-center gap-2 mb-1 justify-center">
|
||||||
@ -402,7 +413,7 @@ export default function MatchReadyOverlay({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[1000]">
|
<div className="fixed inset-0 z-[1000]">
|
||||||
{/* Backdrop: 2s-Fade */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'absolute inset-0 bg-black/60 transition-opacity duration-[300ms] ease-out',
|
'absolute inset-0 bg-black/60 transition-opacity duration-[300ms] ease-out',
|
||||||
@ -410,7 +421,7 @@ export default function MatchReadyOverlay({
|
|||||||
].join(' ')}
|
].join(' ')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content erst nach Backdrop-Delay */}
|
{/* Content */}
|
||||||
{showContent && (
|
{showContent && (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
@ -428,7 +439,7 @@ export default function MatchReadyOverlay({
|
|||||||
className="absolute inset-0 w-full h-full object-cover brightness-90"
|
className="absolute inset-0 w-full h-full object-cover brightness-90"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Deko-Layer (Gif/Video) */}
|
{/* Motion-Layer */}
|
||||||
{useGif ? (
|
{useGif ? (
|
||||||
<div className="absolute inset-0 opacity-50 pointer-events-none">
|
<div className="absolute inset-0 opacity-50 pointer-events-none">
|
||||||
<img
|
<img
|
||||||
@ -454,7 +465,7 @@ export default function MatchReadyOverlay({
|
|||||||
</video>
|
</video>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 🔽 NEU: dunkler Gradient wie bei „Gewählte Maps“ */}
|
{/* Gradient */}
|
||||||
<div className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
|
<div className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
|
||||||
|
|
||||||
{/* Inhalt */}
|
{/* Inhalt */}
|
||||||
@ -498,13 +509,11 @@ export default function MatchReadyOverlay({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Countdown oder Verbinde-Status */}
|
{/* Countdown / Verbinde-Status */}
|
||||||
{/* 🔽 NEU: Countdown ausblenden, wenn der Warte-Hinweis gezeigt wird */}
|
|
||||||
{!showWaitHint && (
|
{!showWaitHint && (
|
||||||
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
|
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
|
||||||
{connecting ? (
|
{connecting ? (
|
||||||
showConnectHelp ? (
|
showConnectHelp ? (
|
||||||
// ⬇️ NEU: nach 30s
|
|
||||||
<span className="inline-flex items-center gap-3 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10">
|
<span className="inline-flex items-center gap-3 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10">
|
||||||
<span className="text-[#f8e08e] font-semibold">Es lädt nicht?</span>
|
<span className="text-[#f8e08e] font-semibold">Es lädt nicht?</span>
|
||||||
<a
|
<a
|
||||||
@ -515,7 +524,6 @@ export default function MatchReadyOverlay({
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
// bisheriges „Verbinde…“
|
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10"
|
className="inline-flex items-center gap-2 px-3 py-1 rounded-md bg-black/45 backdrop-blur-sm ring-1 ring-white/10"
|
||||||
role="status"
|
role="status"
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
|
|||||||
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore'
|
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore'
|
||||||
import TelemetryBanner from './TelemetryBanner'
|
import TelemetryBanner from './TelemetryBanner'
|
||||||
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
|
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
|
||||||
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||||||
|
|
||||||
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
|
||||||
const h = (host ?? '').trim() || '127.0.0.1'
|
const h = (host ?? '').trim() || '127.0.0.1'
|
||||||
@ -46,7 +47,6 @@ function labelForMap(key?: string | null): string {
|
|||||||
const k = String(key).toLowerCase()
|
const k = String(key).toLowerCase()
|
||||||
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === k)
|
const opt = MAP_OPTIONS.find(o => o.key.toLowerCase() === k)
|
||||||
if (opt?.label) return opt.label
|
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()
|
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(' ')
|
s = s.split(' ').map(w => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ')
|
||||||
return s
|
return s
|
||||||
@ -84,8 +84,10 @@ export default function TelemetrySocket() {
|
|||||||
const phase = useTelemetryStore((s) => s.phase)
|
const phase = useTelemetryStore((s) => s.phase)
|
||||||
const setPhase = useTelemetryStore((s) => s.setPhase)
|
const setPhase = useTelemetryStore((s) => s.setPhase)
|
||||||
|
|
||||||
// roster (persisted by ReadyOverlayHost)
|
// roster store
|
||||||
const rosterSet = useMatchRosterStore((s) => s.roster)
|
const rosterSet = useMatchRosterStore((s) => s.roster)
|
||||||
|
const setRoster = useMatchRosterStore((s) => s.setRoster)
|
||||||
|
const clearRoster = useMatchRosterStore((s) => s.clearRoster)
|
||||||
|
|
||||||
// local telemetry state
|
// local telemetry state
|
||||||
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(new Set())
|
const [telemetrySet, setTelemetrySet] = useState<Set<string>>(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<string | null>(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(() => {
|
useEffect(() => {
|
||||||
aliveRef.current = true
|
aliveRef.current = true
|
||||||
|
|
||||||
@ -146,12 +174,10 @@ export default function TelemetrySocket() {
|
|||||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
// --- server name (optional)
|
|
||||||
if (msg.type === 'server' && typeof msg.name === 'string' && msg.name.trim()) {
|
if (msg.type === 'server' && typeof msg.name === 'string' && msg.name.trim()) {
|
||||||
setServerName(msg.name.trim())
|
setServerName(msg.name.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- full roster
|
|
||||||
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
if (msg.type === 'players' && Array.isArray(msg.players)) {
|
||||||
setSnapshot(msg.players)
|
setSnapshot(msg.players)
|
||||||
const ids = msg.players.map(sidOf).filter(Boolean)
|
const ids = msg.players.map(sidOf).filter(Boolean)
|
||||||
@ -162,7 +188,6 @@ export default function TelemetrySocket() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- incremental roster
|
|
||||||
if (msg.type === 'player_join' && msg.player) {
|
if (msg.type === 'player_join' && msg.player) {
|
||||||
setJoin(msg.player)
|
setJoin(msg.player)
|
||||||
setTelemetrySet(prev => {
|
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') {
|
if (msg.type === 'map' && typeof msg.name === 'string') {
|
||||||
const key = msg.name.toLowerCase()
|
const key = msg.name.toLowerCase()
|
||||||
setMapKey(key)
|
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') {
|
if (msg.type === 'phase' && typeof msg.phase === 'string') {
|
||||||
setPhase(String(msg.phase).toLowerCase() as any)
|
setPhase(String(msg.phase).toLowerCase() as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- score (unverändert)
|
// Score
|
||||||
if (msg.type === 'score') {
|
if (msg.type === 'score') {
|
||||||
const a = Number(msg.team1 ?? msg.ct)
|
const a = Number(msg.team1 ?? msg.ct)
|
||||||
const b = Number(msg.team2 ?? msg.t)
|
const b = Number(msg.team2 ?? msg.t)
|
||||||
@ -221,7 +246,7 @@ export default function TelemetrySocket() {
|
|||||||
}
|
}
|
||||||
}, [url, setSnapshot, setJoin, setLeave, setMapKey, setPhase, hideOverlay, mySteamId])
|
}, [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 myId = mySteamId ? String(mySteamId) : null
|
||||||
const roster =
|
const roster =
|
||||||
rosterSet instanceof Set && rosterSet.size > 0
|
rosterSet instanceof Set && rosterSet.size > 0
|
||||||
@ -231,12 +256,8 @@ export default function TelemetrySocket() {
|
|||||||
const iAmExpected = !!myId && roster.has(myId)
|
const iAmExpected = !!myId && roster.has(myId)
|
||||||
const iAmOnline = !!myId && telemetrySet.has(myId)
|
const iAmOnline = !!myId && telemetrySet.has(myId)
|
||||||
|
|
||||||
const intersectCount = (() => {
|
let intersectCount = 0
|
||||||
if (roster.size === 0) return 0
|
for (const sid of roster) if (telemetrySet.has(sid)) intersectCount++
|
||||||
let n = 0
|
|
||||||
for (const sid of roster) if (telemetrySet.has(sid)) n++
|
|
||||||
return n
|
|
||||||
})()
|
|
||||||
const totalExpected = roster.size
|
const totalExpected = roster.size
|
||||||
|
|
||||||
const connectUri =
|
const connectUri =
|
||||||
@ -245,10 +266,7 @@ export default function TelemetrySocket() {
|
|||||||
process.env.NEXT_PUBLIC_CS2_CONNECT_URI ||
|
process.env.NEXT_PUBLIC_CS2_CONNECT_URI ||
|
||||||
'steam://rungameid/730//+retry'
|
'steam://rungameid/730//+retry'
|
||||||
|
|
||||||
// Fallback-Label aus URI, falls kein Servername vom WS kam
|
const effectiveServerLabel = (serverName && serverName.trim()) || parseServerLabel(connectUri)
|
||||||
const fallbackServerLabel = parseServerLabel(connectUri)
|
|
||||||
const effectiveServerLabel = (serverName && serverName.trim()) || fallbackServerLabel
|
|
||||||
|
|
||||||
const prettyPhase = phase ?? 'unknown'
|
const prettyPhase = phase ?? 'unknown'
|
||||||
const prettyScore = (score.a == null || score.b == null) ? '– : –' : `${score.a} : ${score.b}`
|
const prettyScore = (score.a == null || score.b == null) ? '– : –' : `${score.a} : ${score.b}`
|
||||||
const prettyMapLabel = labelForMap(mapKeyForUi)
|
const prettyMapLabel = labelForMap(mapKeyForUi)
|
||||||
@ -258,44 +276,28 @@ export default function TelemetrySocket() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
const handleDisconnect = async () => {
|
||||||
// Auto-Reconnect stoppen
|
aliveRef.current = false
|
||||||
aliveRef.current = false;
|
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
|
||||||
if (retryRef.current) {
|
|
||||||
window.clearTimeout(retryRef.current);
|
|
||||||
retryRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket zu
|
|
||||||
try { wsRef.current?.close(1000, 'user requested disconnect') } catch {}
|
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 {
|
try {
|
||||||
const who = myName || mySteamId;
|
const who = myName || mySteamId
|
||||||
if (who) {
|
if (who) {
|
||||||
const cmd = `kick ${quoteArg(String(who))}`;
|
const cmd = `kick ${quoteArg(String(who))}`
|
||||||
await fetch('/api/cs2/server/send-command', {
|
await fetch('/api/cs2/server/send-command', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
body: JSON.stringify({ command: cmd }),
|
body: JSON.stringify({ command: cmd }),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {}
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
}
|
||||||
console.warn('[TelemetrySocket] kick command failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const variant: 'connected' | 'disconnected' = iAmOnline ? 'connected' : 'disconnected'
|
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 zIndex = iAmOnline ? 9998 : 9999
|
||||||
|
|
||||||
const bannerEl = (
|
const bannerEl = (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user