453 lines
17 KiB
TypeScript
453 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useCallback } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
import Link from 'next/link'
|
||
import Image from 'next/image'
|
||
import { format } from 'date-fns'
|
||
import { de } from 'date-fns/locale'
|
||
import Switch from '@/app/components/Switch'
|
||
import Button from './Button'
|
||
import Modal from './Modal'
|
||
import { Match } from '../types/match'
|
||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||
|
||
type Props = { matchType?: string }
|
||
|
||
const getTeamLogo = (logo?: string | null) =>
|
||
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
|
||
|
||
const toDateKey = (d: Date) => d.toISOString().slice(0, 10)
|
||
const weekdayDE = new Intl.DateTimeFormat('de-DE', { weekday: 'long' })
|
||
|
||
type TeamOption = { id: string; name: string; logo?: string | null }
|
||
|
||
/** lokale Date+Time -> ISO (bewahrt lokale Uhrzeit) */
|
||
function combineLocalDateTime(dateStr: string, timeStr: string) {
|
||
const [y, m, d] = dateStr.split('-').map(Number)
|
||
const [hh, mm] = timeStr.split(':').map(Number)
|
||
const dt = new Date(y, (m - 1), d, hh, mm, 0, 0) // lokale Zeit
|
||
return dt.toISOString()
|
||
}
|
||
|
||
/** nächste volle Stunde als Default */
|
||
function getNextHourDefaults() {
|
||
const now = new Date()
|
||
now.setMinutes(0, 0, 0)
|
||
now.setHours(now.getHours() + 1)
|
||
const dateStr = now.toISOString().slice(0, 10)
|
||
const timeStr = now.toTimeString().slice(0, 5) // HH:MM
|
||
return { dateStr, timeStr }
|
||
}
|
||
|
||
export default function CommunityMatchList({ matchType }: Props) {
|
||
const { data: session } = useSession()
|
||
const router = useRouter()
|
||
const { lastEvent } = useSSEStore()
|
||
|
||
const [matches, setMatches] = useState<Match[]>([])
|
||
const [onlyOwn, setOnlyOwn] = useState(false)
|
||
|
||
// Modal-States
|
||
const [showCreate, setShowCreate] = useState(false)
|
||
const [teams, setTeams] = useState<TeamOption[]>([])
|
||
const [loadingTeams, setLoadingTeams] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const [teamAId, setTeamAId] = useState<string>('')
|
||
const [teamBId, setTeamBId] = useState<string>('')
|
||
const [title, setTitle] = useState<string>('') // auto editierbar
|
||
const [autoTitle, setAutoTitle] = useState(true)
|
||
const [bestOf, setBestOf] = useState<3 | 5>(3)
|
||
|
||
// Datum & Uhrzeit
|
||
const defaults = getNextHourDefaults()
|
||
const [matchDateStr, setMatchDateStr] = useState<string>(defaults.dateStr) // YYYY-MM-DD
|
||
const [matchTimeStr, setMatchTimeStr] = useState<string>(defaults.timeStr) // HH:MM
|
||
|
||
const teamById = useCallback(
|
||
(id?: string) => teams.find(t => t.id === id),
|
||
[teams]
|
||
)
|
||
|
||
useEffect(() => {
|
||
const id = setInterval(() => {
|
||
// force re-render, damit isOpen (Vergleich mit Date.now) neu bewertet wird
|
||
setMatches(m => [...m])
|
||
}, 30_000)
|
||
return () => clearInterval(id)
|
||
}, [])
|
||
|
||
|
||
// Auto-Titel
|
||
useEffect(() => {
|
||
if (!autoTitle) return
|
||
const a = teamById(teamAId)?.name ?? 'Team A'
|
||
const b = teamById(teamBId)?.name ?? 'Team B'
|
||
setTitle(`${a} vs ${b}`)
|
||
}, [teamAId, teamBId, autoTitle, teamById])
|
||
|
||
// Matches laden
|
||
const loadMatches = useCallback(async () => {
|
||
const url = `/api/matches${matchType ? `?type=${encodeURIComponent(matchType)}` : ''}`
|
||
try {
|
||
const r = await fetch(url, { cache: 'no-store' })
|
||
const data = r.ok ? await r.json() : []
|
||
setMatches(data)
|
||
} catch (err) {
|
||
console.error('[MatchList] Laden fehlgeschlagen:', err)
|
||
}
|
||
}, [matchType])
|
||
|
||
useEffect(() => { loadMatches() }, [loadMatches])
|
||
|
||
|
||
useEffect(() => {
|
||
if (!lastEvent) return
|
||
|
||
// auf diese Typen reagieren
|
||
const TRIGGER_TYPES = new Set([
|
||
'match-created',
|
||
'matches-updated',
|
||
'match-deleted',
|
||
'map-vote-updated', // damit das Map-Vote Badge live aktualisiert
|
||
])
|
||
|
||
if (!TRIGGER_TYPES.has(lastEvent.type)) return
|
||
|
||
// kurzer Cooldown, falls mehrere Events gleichzeitig eintreffen
|
||
let cancelled = false
|
||
const t = setTimeout(async () => {
|
||
if (!cancelled) await loadMatches()
|
||
}, 150)
|
||
return () => { cancelled = true; clearTimeout(t) }
|
||
}, [lastEvent, loadMatches])
|
||
|
||
// Teams laden, wenn Modal aufgeht
|
||
useEffect(() => {
|
||
if (!showCreate || teams.length) return
|
||
;(async () => {
|
||
setLoadingTeams(true)
|
||
try {
|
||
const res = await fetch('/api/teams', { cache: 'no-store' })
|
||
const json = await res.json()
|
||
const opts: TeamOption[] = (json.teams ?? []).map((t: any) => ({
|
||
id: t.id, name: t.name, logo: t.logo,
|
||
}))
|
||
setTeams(opts)
|
||
} catch (e) {
|
||
console.error('[MatchList] /api/teams fehlgeschlagen:', e)
|
||
setTeams([])
|
||
} finally {
|
||
setLoadingTeams(false)
|
||
}
|
||
})()
|
||
}, [showCreate, teams.length])
|
||
|
||
const resetCreateState = () => {
|
||
setTeamAId('')
|
||
setTeamBId('')
|
||
setTitle('')
|
||
setAutoTitle(true)
|
||
setBestOf(3)
|
||
const d = getNextHourDefaults()
|
||
setMatchDateStr(d.dateStr)
|
||
setMatchTimeStr(d.timeStr)
|
||
}
|
||
|
||
const canSave =
|
||
!saving &&
|
||
teamAId &&
|
||
teamBId &&
|
||
teamAId !== teamBId &&
|
||
title.trim().length > 0 &&
|
||
(bestOf === 3 || bestOf === 5) &&
|
||
!!matchDateStr &&
|
||
!!matchTimeStr
|
||
|
||
const handleCreate = async () => {
|
||
if (!canSave) return
|
||
setSaving(true)
|
||
try {
|
||
const matchDateISO = combineLocalDateTime(matchDateStr, matchTimeStr)
|
||
const res = await fetch('/api/matches/create', {
|
||
method : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body : JSON.stringify({
|
||
teamAId,
|
||
teamBId,
|
||
title: title.trim(),
|
||
bestOf, // 3 | 5
|
||
matchDate: matchDateISO, // <- Datum+Zeit
|
||
type : matchType, // optional
|
||
}),
|
||
})
|
||
if (!res.ok) {
|
||
const j = await res.json().catch(() => ({}))
|
||
alert(j.message ?? 'Erstellen fehlgeschlagen')
|
||
return
|
||
}
|
||
setShowCreate(false)
|
||
resetCreateState()
|
||
await loadMatches()
|
||
} catch (e) {
|
||
console.error('[MatchList] Match erstellen fehlgeschlagen:', e)
|
||
alert('Match konnte nicht erstellt werden.')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
// Gruppieren
|
||
const grouped = (() => {
|
||
const sorted = [...matches].sort(
|
||
(a, b) => new Date(a.demoDate).getTime() - new Date(b.demoDate).getTime(),
|
||
)
|
||
const map = new Map<string, Match[]>()
|
||
for (const m of sorted) {
|
||
const key = toDateKey(new Date(m.demoDate))
|
||
map.set(key, [...(map.get(key) ?? []), m])
|
||
}
|
||
return Array.from(map.entries())
|
||
})()
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto py-8 px-4 space-y-6">
|
||
{/* Kopfzeile */}
|
||
<div className="flex items-center justify-between flex-wrap gap-y-4">
|
||
<h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300">
|
||
Geplante Matches
|
||
</h1>
|
||
<div className="flex items-center gap-4">
|
||
<Switch
|
||
id="only-own-team"
|
||
checked={onlyOwn}
|
||
onChange={setOnlyOwn}
|
||
labelRight="Nur mein Team anzeigen"
|
||
/>
|
||
{session?.user?.isAdmin && (
|
||
<Button color="blue" onClick={() => setShowCreate(true)}>
|
||
Match erstellen
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Inhalt */}
|
||
{grouped.length === 0 ? (
|
||
<p className="text-gray-700 dark:text-neutral-300">Keine Matches geplant.</p>
|
||
) : (
|
||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||
{grouped.map(([dateKey, dayMatches], dayIdx) => {
|
||
const dateObj = new Date(dateKey + 'T00:00:00')
|
||
const dayLabel = `Tag #${dayIdx + 1} – ${weekdayDE.format(dateObj)}`
|
||
return (
|
||
<div key={dateKey} className="flex flex-col gap-4">
|
||
<div className="bg-yellow-300 dark:bg-yellow-500 text-center py-2 font-bold tracking-wider">
|
||
{dayLabel}<br />{dateKey}
|
||
</div>
|
||
{dayMatches.map((m: Match) => {
|
||
const started = new Date(m.demoDate).getTime() <= Date.now()
|
||
const unfinished = !m.winnerTeam && m.scoreA == null && m.scoreB == null
|
||
const isLive = started && unfinished
|
||
const isOwnTeam = !!session?.user?.team &&
|
||
(m.teamA.id === session.user.team || m.teamB.id === session.user.team)
|
||
const dimmed = onlyOwn && !isOwnTeam
|
||
return (
|
||
<Link
|
||
key={m.id}
|
||
href={`/match-details/${m.id}`}
|
||
className={`
|
||
relative flex flex-col items-center gap-4 bg-neutral-300 dark:bg-neutral-800
|
||
text-gray-800 dark:text-white rounded-sm py-4
|
||
hover:scale-105 hover:bg-neutral-400 hover:dark:bg-neutral-700
|
||
hover:shadow-md h-[172px]
|
||
transition-transform transition-opacity duration-300 ease-in-out
|
||
${dimmed ? 'opacity-40' : 'opacity-100'}
|
||
`}
|
||
>
|
||
{isLive && (
|
||
<span className="absolute top-2 px-2 py-0.5 text-xs font-semibold rounded-full bg-red-300 dark:bg-red-500 text-white">
|
||
LIVE
|
||
</span>
|
||
)}
|
||
|
||
{/* Map-Vote Badge */}
|
||
{m.mapVote && (
|
||
<span
|
||
className={`
|
||
px-2 py-0.5 rounded-full text-[11px] font-semibold
|
||
${m.mapVote.isOpen ? 'bg-green-300 dark:bg-green-600 text-white' : 'bg-neutral-200 dark:bg-neutral-700'}
|
||
`}
|
||
title={
|
||
m.mapVote.opensAt
|
||
? `Öffnet ${format(new Date(m.mapVote.opensAt), 'dd.MM.yyyy HH:mm', { locale: de })} Uhr`
|
||
: undefined
|
||
}
|
||
>
|
||
{m.mapVote.isOpen
|
||
? (m.mapVote.status === 'completed' ? 'Map-Vote abgeschlossen' : 'Map-Vote offen')
|
||
: m.mapVote.opensAt
|
||
? `Map-Vote ab ${format(new Date(m.mapVote.opensAt), 'HH:mm', { locale: de })} Uhr`
|
||
: 'Map-Vote bald'}
|
||
</span>
|
||
)}
|
||
|
||
<div className="flex w-full justify-around items-center">
|
||
<div className="flex flex-col items-center w-1/3">
|
||
<Image src={getTeamLogo(m.teamA.logo)} alt={m.teamA.name} width={48} height={48} className="rounded-full border bg-white" />
|
||
<span className="mt-2 text-xs">{m.teamA.name}</span>
|
||
</div>
|
||
<span className="font-bold">vs</span>
|
||
<div className="flex flex-col items-center w-1/3">
|
||
<Image src={getTeamLogo(m.teamB.logo)} alt={m.teamB.name} width={48} height={48} className="rounded-full border bg-white" />
|
||
<span className="mt-2 text-xs">{m.teamB.name}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col items-center space-y-1 mt-2">
|
||
<span className={`px-3 py-0.5 rounded-full text-sm font-semibold ${isLive ? 'bg-red-300 dark:bg-red-500' : 'bg-yellow-300 dark:bg-yellow-500'}`}>
|
||
{format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })}
|
||
</span>
|
||
|
||
<span className="flex items-center gap-1 text-xs opacity-80">
|
||
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 512 512">
|
||
<path d="M256 48a208 208 0 1 0 208 208A208.24 208.24 0 0 0 256 48Zm0 384a176 176 0 1 1 176-176 176.2 176.2 0 0 1-176 176Zm80-176h-64V144a16 16 0 0 0-32 0v120a16 16 0 0 0 16 16h80a16 16 0 0 0 0-32Z" />
|
||
</svg>
|
||
{format(new Date(m.demoDate), 'HH:mm', { locale: de })} Uhr
|
||
</span>
|
||
</div>
|
||
</Link>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Modal: Match erstellen */}
|
||
<Modal
|
||
id="create-match-modal"
|
||
title="Match erstellen"
|
||
show={showCreate}
|
||
onClose={() => { setShowCreate(false); resetCreateState() }}
|
||
onSave={handleCreate}
|
||
closeButtonTitle={saving ? 'Speichern …' : 'Erstellen'}
|
||
closeButtonColor="blue"
|
||
disableCloseButton={!canSave}
|
||
>
|
||
<div className="space-y-4">
|
||
{/* Team A */}
|
||
<label className="block text-sm font-medium mb-1">Team A</label>
|
||
<select
|
||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||
value={teamAId}
|
||
onChange={(e) => setTeamAId(e.target.value)}
|
||
disabled={loadingTeams}
|
||
>
|
||
<option value="">— bitte wählen —</option>
|
||
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||
</select>
|
||
|
||
{/* Team B */}
|
||
<label className="block text-sm font-medium mb-1 mt-2">Team B</label>
|
||
<select
|
||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||
value={teamBId}
|
||
onChange={(e) => setTeamBId(e.target.value)}
|
||
disabled={loadingTeams}
|
||
>
|
||
<option value="">— bitte wählen —</option>
|
||
{teams.map(t => (
|
||
<option key={t.id} value={t.id} disabled={t.id === teamAId}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* Titel */}
|
||
<div className="mt-3">
|
||
<label className="block text-sm font-medium mb-1">
|
||
Titel {autoTitle && <span className="ml-2 text-xs text-gray-500">(automatisch)</span>}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => { setTitle(e.target.value); setAutoTitle(false) }}
|
||
onFocus={() => setAutoTitle(false)}
|
||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||
placeholder="Team A vs Team B"
|
||
/>
|
||
{!autoTitle && (
|
||
<button
|
||
type="button"
|
||
className="mt-1 text-xs text-blue-600 hover:underline"
|
||
onClick={() => setAutoTitle(true)}
|
||
>
|
||
Titel wieder automatisch generieren
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Datum & Uhrzeit */}
|
||
<div className="grid grid-cols-2 gap-3 mt-3">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Datum</label>
|
||
<input
|
||
type="date"
|
||
value={matchDateStr}
|
||
min={new Date().toISOString().slice(0,10)}
|
||
onChange={(e) => setMatchDateStr(e.target.value)}
|
||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Uhrzeit</label>
|
||
<input
|
||
type="time"
|
||
value={matchTimeStr}
|
||
onChange={(e) => setMatchTimeStr(e.target.value)}
|
||
step={300} // 5-Minuten-Schritte
|
||
className="w-full rounded-lg border border-gray-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-[11px] text-gray-500 dark:text-neutral-400">
|
||
Die Uhrzeit wird als lokale Zeit gespeichert.
|
||
</p>
|
||
|
||
{/* Best-of */}
|
||
<div className="mt-3">
|
||
<label className="block text-sm font-medium mb-1">Modus</label>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setBestOf(3)}
|
||
className={`px-3 py-1.5 rounded-lg text-sm border ${bestOf === 3 ? 'bg-blue-600 text-white border-blue-600' : 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
|
||
>
|
||
BO3
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBestOf(5)}
|
||
className={`px-3 py-1.5 rounded-lg text-sm border ${bestOf === 5 ? 'bg-blue-600 text-white border-blue-600' : 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
|
||
>
|
||
BO5
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{loadingTeams && (
|
||
<p className="text-sm text-gray-500 mt-2">Teams werden geladen …</p>
|
||
)}
|
||
{teamAId && teamBId && teamAId === teamBId && (
|
||
<p className="text-sm text-red-600 mt-2">Bitte zwei unterschiedliche Teams wählen.</p>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|