ironie-nextjs/src/app/components/MatchDetails.tsx
2025-08-14 15:06:48 +02:00

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>
)
}