ironie-nextjs/src/app/[locale]/components/CommunityMatchList.tsx
2025-10-14 15:30:11 +02:00

740 lines
28 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.

// CommunityMatchList.tsx
'use client'
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { useTranslations, useLocale } from 'next-intl'
import Link from 'next/link'
import Image from 'next/image'
import Switch from '../components/Switch'
import Button from './Button'
import Modal from './Modal'
import { Match } from '../../../types/match'
import { useSSEStore } from '@/lib/useSSEStore'
import { useUserTimeZone } from '@/hooks/useUserTimeZone'
type Props = { matchType?: string }
const getTeamLogo = (logo?: string | null) =>
logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp'
type TeamOption = { id: string; name: string; logo?: string | null }
type UnknownRec = Record<string, unknown>;
function isRecord(v: unknown): v is UnknownRec {
return !!v && typeof v === 'object';
}
type PlayerLike = {
user?: { steamId?: string | null } | null;
steamId?: string | null;
};
type TeamMaybeWithPlayers = {
id?: string | null;
players?: PlayerLike[] | null;
};
function toStringOrNull(v: unknown): string | null {
return typeof v === 'string' ? v : null;
}
function firstString(...vals: unknown[]): string {
for (const v of vals) if (typeof v === 'string') return v;
return '';
}
/** 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 }
}
// 👇 Helper für Map-Vote-Status
function getMapVoteState(m: Match, nowMs: number) {
const opensAt = m?.mapVote?.opensAt ? new Date(m.mapVote.opensAt) : null
const locked = m?.mapVote?.locked ?? false
if (!opensAt) return { hasVote: false as const }
const opensAtMs = opensAt.getTime()
const isOpen = !locked && opensAtMs <= nowMs
const opensInMs = Math.max(0, opensAtMs - nowMs) // nie negativ
return { hasVote: true as const, isOpen, opensAt, opensInMs }
}
// ---- Timezone Utils ----
type ZonedParts = {
year: number; month: number; day: number; hour: number; minute: number;
};
function getZonedParts(date: Date | string, timeZone: string, locale = 'de-DE'): ZonedParts {
const d = typeof date === 'string' ? new Date(date) : date;
const parts = new Intl.DateTimeFormat(locale, {
timeZone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(d);
const g = (t: string) => Number(parts.find(p => p.type === t)?.value ?? '0');
return { year: g('year'), month: g('month'), day: g('day'), hour: g('hour'), minute: g('minute') };
}
function formatDateInTZ(date: Date | string, timeZone: string, locale = 'de-DE') {
const p = getZonedParts(date, timeZone, locale);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(p.day)}.${pad(p.month)}.${p.year}`;
}
function formatTimeInTZ(date: Date | string, timeZone: string, locale = 'de-DE') {
const p = getZonedParts(date, timeZone, locale);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(p.hour)}:${pad(p.minute)}`;
}
function dateKeyInTZ(date: Date | string, timeZone: string): string {
const p = getZonedParts(date, timeZone);
const pad = (n: number) => String(n).padStart(2, '0');
return `${p.year}-${pad(p.month)}-${pad(p.day)}`; // YYYY-MM-DD
}
type BanStatus = {
vacBanned?: boolean | null
numberOfVACBans?: number | null
numberOfGameBans?: number | null
communityBanned?: boolean | null
economyBan?: string | null
daysSinceLastBan?: number | null
}
function isBanStatusFlagged(b?: BanStatus | null): boolean {
if (!b) return false
const econ = (b.economyBan ?? 'none').toLowerCase()
const hasEcon = econ !== 'none' && econ !== ''
return (
b.vacBanned === true ||
(b.numberOfVACBans ?? 0) > 0 ||
(b.numberOfGameBans ?? 0) > 0 ||
b.communityBanned === true ||
hasEcon
)
}
/** Liefert Info, ob Match gebannte Spieler enthält (zählt beide Seiten) */
function matchBanInfo(m: Match): { hasBan: boolean; count: number; tooltip: string } {
const teamA = (m.teamA as unknown as TeamMaybeWithPlayers);
const teamB = (m.teamB as unknown as TeamMaybeWithPlayers);
const playersA = Array.isArray(teamA?.players) ? teamA.players! : [];
const playersB = Array.isArray(teamB?.players) ? teamB.players! : [];
// Fallback: flaches players-Array (ältere API)
const flat = (m as unknown as { players?: PlayerLike[] | null }).players ?? [];
const all: PlayerLike[] =
playersA.length || playersB.length ? [...playersA, ...playersB] : Array.isArray(flat) ? flat : [];
let count = 0;
const lines: string[] = [];
for (const p of all) {
const user = (p?.user as unknown as { name?: string | null; banStatus?: BanStatus | null }) ?? {};
const name = user?.name ?? 'Unbekannt';
const b = user?.banStatus ?? null;
if (isBanStatusFlagged(b)) {
count++;
const parts: string[] = [];
if (b?.vacBanned) parts.push('VAC aktiv');
if ((b?.numberOfVACBans ?? 0) > 0) parts.push(`VAC=${b?.numberOfVACBans}`);
if ((b?.numberOfGameBans ?? 0) > 0) parts.push(`Game=${b?.numberOfGameBans}`);
if (b?.communityBanned) parts.push('Community');
if (b?.economyBan && b.economyBan !== 'none') parts.push(`Economy=${b.economyBan}`);
if (typeof b?.daysSinceLastBan === 'number') parts.push(`Tage seit Ban=${b.daysSinceLastBan}`);
lines.push(`${name}: ${parts.join(' · ')}`);
}
}
return { hasBan: count > 0, count, tooltip: lines.join('\n') };
}
export default function CommunityMatchList({ matchType }: Props) {
const { data: session } = useSession()
const locale = useLocale()
const userTZ = useUserTimeZone([session?.user?.steamId])
const weekdayFmt = useMemo(() =>
new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-GB', {
weekday: 'long',
timeZone: userTZ,
}),
[locale, userTZ])
const tMatches = useTranslations('matches')
const tMapvote = useTranslations('mapvote')
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 pad = (n: number) => String(n).padStart(2, '0');
const hours = Array.from({ length: 24 }, (_, i) => i);
const quarters = [0, 15, 30, 45];
const teamById = useCallback(
(id?: string) => teams.find(t => t.id === id),
[teams]
)
const [now, setNow] = useState(() => Date.now())
const mySteamId = session?.user?.steamId
const isOwnMatch = useCallback((m: Match) => {
if (!mySteamId) return false;
const teamA = (m.teamA as unknown as TeamMaybeWithPlayers);
const teamB = (m.teamB as unknown as TeamMaybeWithPlayers);
const inTeamA =
Array.isArray(teamA?.players) &&
teamA.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
const inTeamB =
Array.isArray(teamB?.players) &&
teamB.players!.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
if (inTeamA || inTeamB) return true;
// Fallback: flaches players-Array
const flatPlayers = (m as unknown as { players?: PlayerLike[] | null }).players ?? [];
const inFlat =
Array.isArray(flatPlayers) &&
flatPlayers.some((p) => p?.user?.steamId === mySteamId || p?.steamId === mySteamId);
if (inFlat) return true;
// Optionaler Fallback: Team-Mitgliedschaft
const byTeamMembership =
!!session?.user?.team && (m?.teamA?.id === session.user.team || m?.teamB?.id === session.user.team);
return byTeamMembership;
}, [mySteamId, session?.user?.team]);
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(id)
}, [])
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 (robust gegen verschiedene Response-Shapes)
useEffect(() => {
if (!showCreate) return
let ignore = false
const ctrl = new AbortController()
;(async () => {
setLoadingTeams(true)
try {
const res = await fetch('/api/teams', {
cache: 'no-store',
credentials: 'same-origin', // wichtig: Cookies mitnehmen
signal: ctrl.signal,
})
const json: unknown = await res.json().catch(() => ({}));
const raw: unknown =
(isRecord(json) && Array.isArray(json.teams)) ? json.teams :
(isRecord(json) && Array.isArray(json.data)) ? json.data :
(isRecord(json) && Array.isArray(json.items)) ? json.items :
Array.isArray(json) ? json : [];
const arr: UnknownRec[] = Array.isArray(raw) ? (raw as UnknownRec[]) : [];
const opts: TeamOption[] = arr
.map((r) => {
const id = firstString(r.id, r._id, r.teamId, r.uuid);
const name = firstString(r.name, r.title, r.displayName, r.tag) || 'Unbenanntes Team';
const logo = toStringOrNull(r.logo ?? r.logoUrl ?? r.image) ?? null;
return { id, name, logo };
})
.filter((t) => !!t.id && !!t.name);
if (!ignore) setTeams(opts)
} catch (e) {
if (!ignore) {
console.error('[MatchList] /api/teams fehlgeschlagen:', e)
setTeams([])
}
} finally {
if (!ignore) setLoadingTeams(false)
}
})()
return () => { ignore = true; ctrl.abort() }
}, [showCreate])
useEffect(() => {
if (!showCreate) return
if (teams.length >= 2 && !teamAId && !teamBId) {
setTeamAId(teams[0].id)
setTeamBId(teams[1].id)
}
}, [teams, showCreate, teamAId, teamBId])
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 = useMemo(() => {
// optional filtern
const base = onlyOwn ? matches.filter(isOwnMatch) : matches
const sorted = [...base].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 = dateKeyInTZ(m.demoDate, userTZ)
map.set(key, [...(map.get(key) ?? []), m])
}
return Array.from(map.entries())
}, [matches, onlyOwn, isOwnMatch, userTZ])
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">
{tMatches("title")}
</h1>
<div className="flex items-center gap-4">
<Switch
id="only-own-team"
checked={onlyOwn}
onChange={setOnlyOwn}
labelRight={tMatches("filter")}
/>
{session?.user?.isAdmin && (
<Button color="blue" onClick={() => setShowCreate(true)}>
{tMatches("create-match")}
</Button>
)}
</div>
</div>
{/* Inhalt */}
{grouped.length === 0 ? (
<p className="text-gray-700 dark:text-neutral-300">{tMatches("description")}</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 weekday = weekdayFmt.format(dateObj);
const dayLabel = `${tMatches('day')} #${dayIdx + 1} ${weekday}`;
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 isOwn = isOwnMatch(m)
const dimmed = onlyOwn ? false : !isOwn
const banInfo = matchBanInfo(m)
// 👇 Map-Vote Status berechnen
const mv = getMapVoteState(m, now)
const opensText =
mv.hasVote && !mv.isOpen && mv.opensAt
? `${tMapvote("opens-in")} ${formatCountdown(mv.opensInMs)}`
: null
return (
<Link
key={m.id}
href={`/match-details/${m.id}`}
className={`
grid grid-rows-[auto_1fr_auto] justify-items-center gap-3
bg-neutral-300 dark:bg-neutral-800 text-gray-800 dark:text-white
rounded-sm p-4 min-h-[210px]
hover:scale-105 hover:bg-neutral-400 hover:dark:bg-neutral-700 hover:shadow-md
transition-transform transition-opacity duration-300 ease-in-out
${dimmed ? 'opacity-40' : 'opacity-100'}
${banInfo.hasBan ? 'ring-2 ring-red-500/70 bg-red-900/10' : ''}
`}
>
{/* Zeile 1: Badges (immer Platz, auch wenn leer) */}
<div className="flex flex-col items-center gap-1 min-h-6">
{isLive ? (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-red-500 text-white shadow">
LIVE
</span>
) : mv.hasVote && (mv.isOpen || opensText) ? (
<>
{mv.isOpen && (
<span className="px-2 py-0.5 text-[11px] font-semibold rounded-full bg-green-600 text-white shadow">
Map-Vote offen
</span>
)}
{!mv.isOpen && opensText && (
<span
title={mv.opensAt?.toLocaleString('de-DE') ?? undefined}
className="px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-300 text-gray-900 dark:bg-yellow-500 dark:text-black shadow"
>
Map-Vote {opensText}
</span>
)}
</>
) : null}
</div>
{/* Zeile 2: Teams */}
<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 ?? 'Team A'}
width={56}
height={56}
className="rounded-full border bg-white"
/>
<span className="mt-2 text-xs text-center line-clamp-1">{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 ?? 'Team B'}
width={56}
height={56}
className="rounded-full border bg-white"
/>
<span className="mt-2 text-xs text-center line-clamp-1">{m.teamB.name}</span>
</div>
</div>
{/* Zeile 3: Datum & Uhrzeit */}
<div className="flex flex-col items-center -mt-1 space-y-1">
{/* Datum-Badge */}
<span
className={`px-3 py-1 rounded-full text-[13px] font-bold shadow ring-1 ring-black/10
${isLive ? 'bg-red-500 text-white' : 'bg-yellow-400 text-gray-900 dark:bg-yellow-500 dark:text-black'}
`}
>
{formatDateInTZ(m.demoDate, userTZ, locale === 'de' ? 'de-DE' : 'en-GB')}
</span>
<span className="flex items-center gap-1 text-xs font-semibold opacity-90">
<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>
{formatTimeInTZ(m.demoDate, userTZ, locale === 'de' ? 'de-DE' : 'en-GB')} 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"
>
<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="mt-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
{/* Links: Datum */}
<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>
{/* Rechts: Uhrzeit (HH + MM in Vierteln) */}
<div>
<label className="block text-sm font-medium mb-1">Uhrzeit</label>
<div className="flex gap-2">
{/* Stunde */}
<select
value={matchTimeStr.split(':')[0]}
onChange={(e) => {
const [, mm] = matchTimeStr.split(':');
setMatchTimeStr(`${pad(Number(e.target.value))}:${mm}`);
}}
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"
>
{hours.map(h => <option key={h} value={pad(h)}>{pad(h)}</option>)}
</select>
{/* Minuten (00/15/30/45) */}
<select
value={pad(Math.round(Number(matchTimeStr.split(':')[1]) / 15) * 15 % 60)}
onChange={(e) => {
const [hh] = matchTimeStr.split(':');
setMatchTimeStr(`${hh}:${pad(Number(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"
>
{quarters.map(q => <option key={q} value={pad(q)}>{pad(q)}</option>)}
</select>
</div>
</div>
</div>
<p className="text-[11px] text-gray-500 dark:text-neutral-400 mt-1">
Datum &amp; Uhrzeit werden als lokale Zeit gespeichert.
</p>
</div>
{/* 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>
)}
{!loadingTeams && showCreate && teams.length === 0 && (
<p className="text-sm text-amber-600">
Keine Teams gefunden. Prüfe den /api/teams Response (erwartet id & name).
</p>
)}
</div>
</Modal>
</div>
)
}
function formatCountdown(ms: number) {
if (ms <= 0) return '0:00:00'
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
const pad = (n:number)=>String(n).padStart(2,'0')
return `${h}:${pad(m)}:${pad(s)}`
}