ironie-nextjs/src/app/components/MapVetoBanner.tsx
2025-08-14 15:06:48 +02:00

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