740 lines
28 KiB
TypeScript
740 lines
28 KiB
TypeScript
// 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 & 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)}`
|
||
}
|