188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
// MapVetoBanner.tsx
|
|
'use client'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useSession } from 'next-auth/react'
|
|
import { useSSEStore } from '@/app/lib/useSSEStore'
|
|
import type { MapVetoState } from '../types/mapveto'
|
|
|
|
type Props = { match: any; initialNow: number }
|
|
|
|
export default function MapVetoBanner({ match, initialNow }: Props) {
|
|
const router = useRouter()
|
|
const { data: session } = useSession()
|
|
const { lastEvent } = useSSEStore()
|
|
|
|
// ✅ eine Uhr, deterministisch bei Hydration (kommt als Prop vom Server)
|
|
const [now, setNow] = useState(initialNow)
|
|
|
|
const [state, setState] = useState<MapVetoState | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setError(null)
|
|
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
|
|
if (!r.ok) {
|
|
const j = await r.json().catch(() => ({}))
|
|
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
|
}
|
|
const json = await r.json()
|
|
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
|
|
setState(json)
|
|
} catch (e: any) {
|
|
setState(null)
|
|
setError(e?.message ?? 'Unbekannter Fehler')
|
|
}
|
|
}, [match.id])
|
|
|
|
// ✅ tickt NUR im Client, nach Hydration
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
return () => clearInterval(id)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
// Live-Refresh via SSE
|
|
useEffect(() => {
|
|
if (!lastEvent || lastEvent.type !== 'map-vote-updated') return
|
|
if (lastEvent.payload?.matchId !== match.id) return
|
|
load()
|
|
}, [lastEvent, match.id, load])
|
|
|
|
// Öffnet 1h vor Match-/Demotermin (stabil, ohne Date.now() im Render)
|
|
const opensAt = useMemo(() => {
|
|
if (state?.opensAt) return new Date(state.opensAt).getTime()
|
|
const base = new Date(match.matchDate ?? match.demoDate ?? initialNow)
|
|
return base.getTime() - 60 * 60 * 1000
|
|
}, [state?.opensAt, match.matchDate, match.demoDate, initialNow])
|
|
|
|
const isOpen = now >= opensAt
|
|
const msToOpen = Math.max(opensAt - now, 0)
|
|
|
|
const current = state?.steps?.[state.currentIndex]
|
|
const whoIsUp = current?.teamId
|
|
? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name)
|
|
: null
|
|
|
|
// ⚠️ leader ist bei dir ein Player-Objekt → .steamId vergleichen
|
|
const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session.user.steamId
|
|
const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session.user.steamId
|
|
const isAdmin = !!session?.user?.isAdmin
|
|
const iCanAct = Boolean(
|
|
isOpen &&
|
|
!state?.locked &&
|
|
current?.teamId &&
|
|
(isAdmin ||
|
|
(current.teamId === match.teamA?.id && isLeaderA) ||
|
|
(current.teamId === match.teamB?.id && isLeaderB))
|
|
)
|
|
|
|
const gotoFullPage = () => router.push(`/match-details/${match.id}/vote`)
|
|
|
|
const cardClasses =
|
|
'relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' +
|
|
'dark:border-neutral-700 shadow-sm transition cursor-pointer focus:outline-none ' +
|
|
(isOpen
|
|
? 'ring-1 ring-blue-500/20 hover:ring-blue-500/30 hover:shadow-md'
|
|
: 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md')
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={gotoFullPage}
|
|
onKeyDown={(e) => e.key === 'Enter' && gotoFullPage()}
|
|
className={cardClasses}
|
|
aria-label="Map-Vote öffnen"
|
|
>
|
|
{isOpen && <div aria-hidden className="absolute inset-0 z-0 pointer-events-none mapVoteGradient" />}
|
|
|
|
<div className="relative z-[1] px-4 py-3 flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="shrink-0 w-9 h-9 rounded-full grid place-items-center bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="w-5 h-5" fill="currentColor">
|
|
<path d="M15 4.5 9 7.5l-6-3v15l6 3 6-3 6 3v-15l-6-3Zm-6 16.5-4-2V6l4 2v13Zm2-13 4-2v13l-4 2V8Z"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<div className="font-medium text-gray-900 dark:text-neutral-100">
|
|
Map-Vote
|
|
</div>
|
|
<div className="text-xs text-gray-600 dark:text-neutral-400 truncate">
|
|
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
|
|
{state?.locked
|
|
? ' • Auswahl fixiert'
|
|
: isOpen
|
|
? (whoIsUp ? ` • am Zug: ${whoIsUp}` : ' • läuft')
|
|
: ' • startet 1h vor Matchbeginn'}
|
|
</div>
|
|
{error && (
|
|
<div className="text-xs text-red-600 dark:text-red-400 mt-0.5">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0">
|
|
{state?.locked ? (
|
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200">
|
|
Veto abgeschlossen
|
|
</span>
|
|
) : isOpen ? (
|
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
|
|
{iCanAct ? 'Jetzt wählen' : 'Map-Vote offen'}
|
|
</span>
|
|
) : (
|
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-100">
|
|
Öffnet in {formatCountdown(msToOpen)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
@keyframes slide-x {
|
|
from { background-position-x: 0%; }
|
|
to { background-position-x: 200%; }
|
|
}
|
|
.mapVoteGradient {
|
|
background-image: repeating-linear-gradient(
|
|
90deg,
|
|
rgba(37, 99, 235, 0.20) 0%,
|
|
rgba(37, 99, 235, 0.05) 50%,
|
|
rgba(37, 99, 235, 0.20) 100%
|
|
);
|
|
background-size: 200% 100%;
|
|
background-repeat: repeat-x;
|
|
animation: slide-x 3s linear infinite;
|
|
}
|
|
:global(.dark) .mapVoteGradient {
|
|
background-image: repeating-linear-gradient(
|
|
90deg,
|
|
rgba(37, 99, 235, 0.30) 0%,
|
|
rgba(37, 99, 235, 0.10) 50%,
|
|
rgba(37, 99, 235, 0.30) 100%
|
|
);
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.mapVoteGradient { animation: none; }
|
|
}
|
|
`}</style>
|
|
</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)}`
|
|
}
|