ironie-nextjs/src/app/components/CommunityMatchList.tsx
2025-08-17 23:22:06 +02:00

453 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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