379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
/* ────────────────────────────────────────────────────────────────
|
|
/app/components/MatchDetails.tsx
|
|
- Zeigt pro Team einen eigenen „Spieler bearbeiten“-Button
|
|
- Öffnet das Modal nur für das angeklickte Team
|
|
- Reagiert auf SSE-Events (match-lineup-updated / matches-updated)
|
|
─────────────────────────────────────────────────────────────────*/
|
|
'use client'
|
|
|
|
import { useState, useEffect, useMemo } 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 MapVetoBanner from './MapVetoBanner'
|
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
|
import { Team } from '../types/team'
|
|
import Alert from './Alert'
|
|
import Image from 'next/image'
|
|
import { MATCH_EVENTS } from '../lib/sseEvents'
|
|
|
|
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(/^.*\//, '')
|
|
|
|
/* ─────────────────── 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
|
|
const [now, setNow] = useState(initialNow)
|
|
const [editMetaOpen, setEditMetaOpen] = useState(false)
|
|
|
|
/* ─── 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 ─────────────────────────────────────────────────── */
|
|
const mapKey = normalizeMapKey(match.map)
|
|
const mapLabel =
|
|
MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ??
|
|
MAP_OPTIONS.find(opt => opt.key === 'lobby_mapveto')?.label ??
|
|
'Unbekannte Map'
|
|
|
|
/* ─── Match-Zeitpunkt ─────────────────────────────────────── */
|
|
const dateString = match.matchDate ?? match.demoDate
|
|
const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt'
|
|
|
|
/* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */
|
|
const [editSide, setEditSide] = useState<EditSide | null>(null)
|
|
|
|
/* ─── Live-Uhr (für Veto-Zeitpunkt) ───────────────────────── */
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
return () => clearInterval(id)
|
|
}, [])
|
|
|
|
const vetoOpensAtTs = useMemo(() => {
|
|
const base = match.mapVeto?.opensAt
|
|
? new Date(match.mapVeto.opensAt).getTime()
|
|
: new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() - 60 * 60 * 1000
|
|
return base
|
|
}, [match.mapVeto?.opensAt, match.matchDate, match.demoDate, initialNow])
|
|
|
|
const endDate = new Date(vetoOpensAtTs)
|
|
const mapVetoStarted = (match.mapVeto?.isOpen ?? false) || now >= vetoOpensAtTs
|
|
|
|
const showEditA = canEditA && !mapVetoStarted
|
|
const showEditB = canEditB && !mapVetoStarted
|
|
|
|
/* ─── SSE-Listener ─────────────────────────────────────────── */
|
|
useEffect(() => {
|
|
if (!lastEvent) return
|
|
|
|
// Match gelöscht? → zurück zur Liste
|
|
if (lastEvent.type === 'match-deleted' && lastEvent.payload?.matchId === match.id) {
|
|
router.replace('/schedule')
|
|
return
|
|
}
|
|
|
|
// Alle Match-Events → Seite frisch rendern
|
|
if (MATCH_EVENTS.has(lastEvent.type) && lastEvent.payload?.matchId === match.id) {
|
|
router.refresh()
|
|
}
|
|
}, [lastEvent, match.id, router])
|
|
|
|
/* ─── 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">
|
|
<h1 className="text-2xl font-bold">
|
|
Match auf {mapLabel} ({match.matchType})
|
|
</h1>
|
|
|
|
{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>
|
|
)}
|
|
|
|
<p className="text-sm text-gray-500">Datum: {readableDate}</p>
|
|
|
|
<div className="text-md">
|
|
<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>
|
|
|
|
<MapVetoBanner match={match} initialNow={initialNow} />
|
|
|
|
{/* ───────── 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>
|
|
|
|
{showEditA && (
|
|
<Alert type="soft" color="info" className="flex items-center justify-between gap-4">
|
|
<span>
|
|
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setEditSide('A')}
|
|
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
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>
|
|
|
|
{showEditB && (
|
|
<Alert type="soft" color="info" className="flex items-center justify-between gap-4">
|
|
<span>
|
|
Du kannst die Aufstellung noch bis <strong>{format(endDate, 'dd.MM.yyyy HH:mm')}</strong> bearbeiten.
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setEditSide('B')}
|
|
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
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()} // sanfter als window.location.reload()
|
|
/>
|
|
)}
|
|
|
|
{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}
|
|
defaultVetoLeadMinutes={60}
|
|
onSaved={() => { router.refresh() }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|