ironie-nextjs/src/app/components/MatchDetails.tsx
2025-09-04 15:02:14 +02:00

587 lines
23 KiB
TypeScript

// /app/components/MatchDetails.tsx
'use client'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Table from './Table'
import PremierRankBadge from './PremierRankBadge'
import CompRankBadge from './CompRankBadge'
import EditMatchMetaModal from './EditMatchMetaModal'
import EditMatchPlayersModal from './EditMatchPlayersModal'
import type { EditSide } from './EditMatchPlayersModal'
import type { Match, MatchPlayer } from '../types/match'
import Button from './Button'
import { MAP_OPTIONS } from '../lib/mapOptions'
import MapVoteBanner from './MapVoteBanner'
import { useSSEStore } from '@/app/lib/useSSEStore'
import { Team } from '../types/team'
import Alert from './Alert'
import Image from 'next/image'
import Link from 'next/link'
type TeamWithPlayers = Team & { players?: MatchPlayer[] }
/* ─────────────────── Hilfsfunktionen ────────────────────────── */
const kdr = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number'
? d === 0
? '∞'
: (k / d).toFixed(2)
: '-'
const adr = (dmg?: number, rounds?: number) =>
typeof dmg === 'number' && typeof rounds === 'number' && rounds > 0
? (dmg / rounds).toFixed(1)
: '-'
const normalizeMapKey = (raw?: string) =>
(raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
type VoteAction = 'BAN' | 'PICK' | 'DECIDER'
type VoteStep = { order: number; action: VoteAction; map?: string | null }
const mapLabelFromKey = (key?: string) => {
const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'')
return (
MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD')
)
}
// Maps aus dem MapVote (nur PICK/DECIDER, sortiert)
function extractSeriesMaps(match: Match): string[] {
const steps = (match.mapVote?.steps ?? []) as unknown as VoteStep[]
const picks = steps
.filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER'))
.sort((a,b) => (a.order ?? 0) - (b.order ?? 0))
.map(s => s.map ?? '')
const n = Math.max(1, match.bestOf ?? 1)
return picks.slice(0, n)
}
function SeriesStrip({
bestOf,
scoreA = 0,
scoreB = 0,
maps,
}: {
bestOf: number
scoreA?: number | null
scoreB?: number | null
maps: string[]
}) {
const winsA = Math.max(0, scoreA ?? 0)
const winsB = Math.max(0, scoreB ?? 0)
const needed = Math.ceil(bestOf / 2)
const total = Math.max(bestOf, maps.length || 1)
const finished = winsA >= needed || winsB >= needed
const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1)
return (
<div className="w-full">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-gray-400">
Best of {bestOf} First to {needed}
</div>
<div className="text-sm font-semibold">
{winsA}:{winsB}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{Array.from({ length: total }).map((_, i) => {
const key = maps[i] ?? ''
const label = mapLabelFromKey(key)
const isDone = i < winsA + winsB
const isCurrent = i === currentIdx
const isFuture = i > winsA + winsB
return (
<div
key={`series-map-${i}`}
className={[
'rounded-md px-3 py-2 border flex items-center justify-between',
isCurrent
? 'border-blue-500 ring-2 ring-blue-300/50 bg-blue-500/10'
: isDone
? 'border-emerald-500 bg-emerald-500/10'
: 'border-gray-600 bg-neutral-800/40',
].join(' ')}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs opacity-70 shrink-0">Map {i + 1}</span>
<span className="font-medium truncate">{label}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{isDone && (
<span className="text-emerald-400 text-xs font-semibold"></span>
)}
{isCurrent && !isDone && !isFuture && (
<span className="text-blue-400 text-[11px] font-semibold">LIVE / als Nächstes</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
/* ─────────────────── Komponente ─────────────────────────────── */
export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) {
const { data: session } = useSession()
const { lastEvent } = useSSEStore()
const router = useRouter()
const isAdmin = !!session?.user?.isAdmin
// Hydration-sicher: keine sich ändernden Werte im SSR rendern
// Wir brauchen "now" nur, um zu entscheiden, ob Mapvote schon gestartet ist
const [now, setNow] = useState(initialNow)
const [editMetaOpen, setEditMetaOpen] = useState(false)
// Lokale Overrides (analog MapVoteBanner), damit die Clients sofort reagieren
const [opensAtOverride, setOpensAtOverride] = useState<number | null>(null)
const [leadOverride, setLeadOverride] = useState<number | null>(null)
const lastHandledKeyRef = useRef<string>('')
/* ─── Rollen & Rechte ─────────────────────────────────────── */
const me = session?.user
const userId = me?.steamId
const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId
const isLeaderB = !!userId && userId === match.teamB?.leader?.steamId
const canEditA = isAdmin || isLeaderA
const canEditB = isAdmin || isLeaderB
const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? []
const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? []
/* ─── Map-Label ───────────────────────────────────────────── */
const mapKey = normalizeMapKey(match.map)
const mapLabel =
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ??
'Unbekannte Map'
/* ─── Match-Zeitpunkt (vom Server; ändert sich via router.refresh) ─── */
const dateString = match.matchDate ?? match.demoDate
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
const seriesMaps = useMemo(() => {
const fromVote = extractSeriesMaps(match)
const n = Math.max(1, match.bestOf ?? 1)
return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')]
}, [match.bestOf, match.mapVote?.steps?.length])
/* ─── Modal-State ─────────────────────────────────────────── */
const [editSide, setEditSide] = useState<EditSide | null>(null)
/* ─── Live-Uhr für Mapvote-Startfenster ───────────────────── */
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
// Basiszeit des Matches (stabil; für Berechnung von opensAt-Fallback)
const matchBaseTs = useMemo(() => {
const raw = match.matchDate ?? match.demoDate ?? initialNow
return new Date(raw).getTime()
}, [match.matchDate, match.demoDate, initialNow])
// Zeitpunkt, wann der Mapvote öffnet (Parent errechnet und an Banner gereicht)
const voteOpensAtTs = useMemo(() => {
if (opensAtOverride != null) return opensAtOverride
if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime()
const lead = (leadOverride != null)
? leadOverride
: (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60)
return matchBaseTs - lead * 60_000
}, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride])
const sseOpensAtTs = voteOpensAtTs
const sseLeadMinutes = leadOverride
const endDate = new Date(voteOpensAtTs)
const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs
const showEditA = canEditA && !mapvoteStarted
const showEditB = canEditB && !mapvoteStarted
/* ─── SSE-Listener (nur map-vote-updated & Co.) ───────────── */
useEffect(() => {
if (!lastEvent) return
// robustes Unwrap
const outer = lastEvent as any
const maybeInner = outer?.payload
const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner)
? maybeInner
: outer
const type = base?.type
const evt = base?.payload ?? base
if (!evt?.matchId || evt.matchId !== match.id) return
// Dedupe-Key
const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}`
if (key === lastHandledKeyRef.current) {
// identisches Event bereits verarbeitet → ignorieren
return
}
lastHandledKeyRef.current = key
// eigentliche Verarbeitung
if (type === 'map-vote-updated') {
if (evt?.opensAt) {
const ts = new Date(evt.opensAt).getTime()
setOpensAtOverride(ts)
}
if (Number.isFinite(evt?.leadMinutes)) {
const lead = Number(evt.leadMinutes)
setLeadOverride(lead)
if (!evt?.opensAt) {
const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime()
setOpensAtOverride(baseTs - lead * 60_000)
}
}
// damit match.matchDate & Co. neu vom Server kommen
router.refresh()
return
}
const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated'])
if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) {
router.refresh()
}
}, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow])
/* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */
const ColGroup = () => (
<colgroup>
<col style={{ width: '24%' }} />
<col style={{ width: '8%' }} />
{Array.from({ length: 13 }).map((_, i) => (
<col key={i} style={{ width: '5.666%' }} />
))}
</colgroup>
)
/* ─── Match löschen ────────────────────────────────────────── */
const handleDelete = async () => {
if (!confirm('Match wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return
try {
const res = await fetch(`/api/matches/${match.id}/delete`, { method: 'POST' })
if (!res.ok) {
const j = await res.json().catch(() => ({}))
alert(j.message ?? 'Löschen fehlgeschlagen')
return
}
router.push('/schedule')
} catch (e) {
console.error('[MatchDetails] delete failed', e)
alert('Löschen fehlgeschlagen.')
}
}
/* ─── Spieler-Tabelle (pure; keine Hooks hier drin!) ──────── */
const renderTable = (players: MatchPlayer[]) => {
const sorted = [...players].sort(
(a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0),
)
return (
<Table>
<ColGroup />
<Table.Head>
<Table.Row>
{[
'Spieler', 'Rank', 'Aim', 'K', 'A', 'D',
'1K', '2K', '3K', '4K', '5K',
'K/D', 'ADR', 'HS%', 'Damage',
].map((h) => (
<Table.Cell key={h} as="th">
{h}
</Table.Cell>
))}
</Table.Row>
</Table.Head>
<Table.Body>
{sorted.map((p) => (
<Table.Row key={p.user.steamId}>
<Table.Cell
className="py-1 flex items-center gap-2"
hoverable
onClick={() => router.push(`/profile/${p.user.steamId}`)}
>
<img
src={p.user.avatar || '/assets/img/avatars/default_steam_avatar.jpg'}
alt={p.user.name}
className="w-8 h-8 rounded-full mr-3"
/>
<div className="font-semibold text-base">{p.user.name ?? 'Unbekannt'}</div>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-[6px]">
{match.matchType === 'premier'
? <PremierRankBadge rank={p.stats?.rankNew ?? 0} />
: <CompRankBadge rank={p.stats?.rankNew ?? 0} />}
{match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && (
<span
className={`text-sm ${
p.stats.rankChange > 0
? 'text-green-500'
: p.stats.rankChange < 0
? 'text-red-500'
: ''
}`}
>
{p.stats.rankChange > 0 ? '+' : ''}
{p.stats.rankChange}
</span>
)}
</div>
</Table.Cell>
<Table.Cell>
{Number.isFinite(Number(p.stats?.aim))
? `${Number(p.stats?.aim).toFixed(0)} %`
: '-'}
</Table.Cell>
<Table.Cell>{p.stats?.kills ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.assists ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.deaths ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.oneK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.twoK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.threeK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fourK ?? '-'}</Table.Cell>
<Table.Cell>{p.stats?.fiveK ?? '-'}</Table.Cell>
<Table.Cell>{kdr(p.stats?.kills, p.stats?.deaths)}</Table.Cell>
<Table.Cell>{adr(p.stats?.totalDamage, match.roundCount)}</Table.Cell>
<Table.Cell>{((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}%</Table.Cell>
<Table.Cell>{p.stats?.totalDamage?.toFixed(0) ?? '-'}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
)
}
/* ─── Render ─────────────────────────────────────────────── */
return (
<div className="space-y-6">
{/* Kopfzeile: Zurück + Admin-Buttons */}
<div className="flex items-center justify-between">
<Link href="/schedule">
<Button color="gray" variant="outline">
Zurück
</Button>
</Link>
{isAdmin && (
<div className="flex gap-2">
<Button
onClick={() => setEditMetaOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md"
>
Match bearbeiten
</Button>
<Button
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md"
>
Match löschen
</Button>
</div>
)}
</div>
<h1 className="text-2xl font-bold">
Match auf {mapLabel} ({match.matchType})
</h1>
{/* Hydration-sicher: Datum kommt vom Server und ändert sich nach SSE via router.refresh() */}
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
{(match.bestOf ?? 1) > 1 && (
<div className="mt-3">
<SeriesStrip
bestOf={match.bestOf ?? 3}
scoreA={match.scoreA}
scoreB={match.scoreB}
maps={extractSeriesMaps(match)}
/>
</div>
)}
<div className="text-md mt-2">
<strong>Teams:</strong> {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
</div>
<div className="text-md">
<strong>Score:</strong> {match.scoreA ?? 0}:{match.scoreB ?? 0}
</div>
{/* MapVote-Banner erhält die aktuell berechneten (SSE-konformen) Werte */}
<MapVoteBanner
match={match}
initialNow={initialNow}
matchBaseTs={matchBaseTs}
sseOpensAtTs={sseOpensAtTs}
sseLeadMinutes={sseLeadMinutes}
/>
{/* ───────── Team-Blöcke ───────── */}
<div className="border-t pt-4 mt-4 space-y-10">
{/* Team A */}
<div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-xl font-semibold">{match.teamA?.name ?? 'Team A'}</h2>
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
{canEditA && !mapvoteStarted ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
</svg>
)}
<span className='text-gray-300'>
{canEditA && !mapvoteStarted ? (
<>
Du kannst die Aufstellung noch bis{' '}
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
</>
) : (
<>Die Aufstellung kann nicht mehr bearbeitet werden.</>
)}
</span>
</div>
<Button
size="sm"
onClick={() => (canEditA && !mapvoteStarted) && setEditSide('A')}
disabled={!(canEditA && !mapvoteStarted)}
className={`px-3 py-1.5 text-sm rounded-lg ${
canEditA && !mapvoteStarted
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
}`}
>
Spieler bearbeiten
</Button>
</Alert>
</div>
{renderTable(teamAPlayers)}
</div>
{/* Team B */}
<div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-xl font-semibold">
{match.teamB?.logo && (
<span className="relative inline-block w-8 h-8 mr-2 align-middle">
<Image
src={match.teamB.logo ? `/assets/img/logos/${match.teamB.logo}` : `/assets/img/logos/cs2.webp`}
alt="Teamlogo"
fill
sizes="64px"
quality={75}
priority={false}
/>
</span>
)}
{match.teamB?.name ?? 'Team B'}
</h2>
<Alert type="soft" color="dark" className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
{canEditB && !mapvoteStarted ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="green" height="20" width="20" viewBox="0 0 640 640">
<path d="M416 160C416 124.7 444.7 96 480 96C515.3 96 544 124.7 544 160L544 192C544 209.7 558.3 224 576 224C593.7 224 608 209.7 608 192L608 160C608 89.3 550.7 32 480 32C409.3 32 352 89.3 352 160L352 224L192 224C156.7 224 128 252.7 128 288L128 512C128 547.3 156.7 576 192 576L448 576C483.3 576 512 547.3 512 512L512 288C512 252.7 483.3 224 448 224L416 224L416 160z"/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="red" height="20" width="20" viewBox="0 0 640 640">
<path d="M256 160L256 224L384 224L384 160C384 124.7 355.3 96 320 96C284.7 96 256 124.7 256 160zM192 224L192 160C192 89.3 249.3 32 320 32C390.7 32 448 89.3 448 160L448 224C483.3 224 512 252.7 512 288L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 288C128 252.7 156.7 224 192 224z"/>
</svg>
)}
<span className='text-gray-300'>
{canEditB && !mapvoteStarted ? (
<>
Du kannst die Aufstellung noch bis{' '}
<strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
</>
) : (
<>Die Aufstellung kann nicht mehr bearbeitet werden.</>
)}
</span>
</div>
<Button
size="sm"
onClick={() => (canEditB && !mapvoteStarted) && setEditSide('B')}
disabled={!(canEditB && !mapvoteStarted)}
className={`px-3 py-1.5 text-sm rounded-lg ${
canEditB && !mapvoteStarted
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-400 text-gray-200 cursor-not-allowed'
}`}
>
Spieler bearbeiten
</Button>
</Alert>
</div>
{renderTable(teamBPlayers)}
</div>
</div>
{/* ───────── Modal ───────── */}
{editSide && (
<EditMatchPlayersModal
show
onClose={() => setEditSide(null)}
matchId={match.id}
teamA={match.teamA}
teamB={match.teamB}
side={editSide}
initialA={teamAPlayers.map((mp) => mp.user.steamId)}
initialB={teamBPlayers.map((mp) => mp.user.steamId)}
onSaved={() => router.refresh()}
/>
)}
{editMetaOpen && (
<EditMatchMetaModal
show
onClose={() => setEditMetaOpen(false)}
matchId={match.id}
defaultTitle={match.title}
defaultTeamAId={match.teamA?.id ?? null}
defaultTeamBId={match.teamB?.id ?? null}
defaultTeamAName={match.teamA?.name ?? null}
defaultTeamBName={match.teamB?.name ?? null}
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null}
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
/>
)}
</div>
)
}