diff --git a/src/app/api/matches/[matchId]/mapvote/route.ts b/src/app/api/matches/[matchId]/mapvote/route.ts index c89f905..3024133 100644 --- a/src/app/api/matches/[matchId]/mapvote/route.ts +++ b/src/app/api/matches/[matchId]/mapvote/route.ts @@ -19,6 +19,8 @@ const ACTION_MAP: Record = { /* -------------------- Helper -------------------- */ +const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); + // Admin-Edit-Flag setzen/zurücksetzen async function setAdminEdit(voteId: string, by: string | null) { return prisma.mapVote.update({ @@ -497,6 +499,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st // ➕ match-ready senden (erste Map + Teilnehmer) if (updated?.locked) { + await sleep(3000); const chosen = deriveChosenSteps(updated) const first = chosen[0] const key = first?.map ?? null @@ -550,6 +553,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st // ➕ match-ready senden if (updated?.locked) { + await sleep(3000); const chosen = deriveChosenSteps(updated) const first = chosen[0] const key = first?.map ?? null @@ -642,6 +646,7 @@ export async function POST(req: NextRequest, { params }: { params: { matchId: st // Falls durch diesen Schritt locked wurde → Export & match-ready if (updated?.locked) { + await sleep(3000); const chosen = deriveChosenSteps(updated) const first = chosen[0] const key = first?.map ?? null diff --git a/src/app/components/Button.tsx b/src/app/components/Button.tsx index f69dad2..e0090bd 100644 --- a/src/app/components/Button.tsx +++ b/src/app/components/Button.tsx @@ -168,12 +168,12 @@ const Button = forwardRef(function Button( > {loading && ( )} - {loading ? 'Lädt' : (children ?? title)} + {children ?? title} ) }) diff --git a/src/app/components/CommunityMatchList.tsx b/src/app/components/CommunityMatchList.tsx index 2b94695..d63be13 100644 --- a/src/app/components/CommunityMatchList.tsx +++ b/src/app/components/CommunityMatchList.tsx @@ -267,32 +267,18 @@ export default function CommunityMatchList({ matchType }: Props) { ${dimmed ? 'opacity-40' : 'opacity-100'} `} > - {isLive && ( - + {/* Live / Map-Vote Badge */} + {isLive ? ( + LIVE - )} - - {/* Map-Vote Badge */} - {m.mapVote && ( - - {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'} - - )} + ) : (m.mapVote?.isOpen ? ( + + Map-Vote offen + + ) : null + ) + }
@@ -306,12 +292,17 @@ export default function CommunityMatchList({ matchType }: Props) {
-
- + {/* Datum + Uhrzeit: höher & highlight */} +
+ {format(new Date(m.demoDate), 'dd.MM.yyyy', { locale: de })} - + diff --git a/src/app/components/CompRankBadge.tsx b/src/app/components/CompRankBadge.tsx index 6fc4299..f990d7b 100644 --- a/src/app/components/CompRankBadge.tsx +++ b/src/app/components/CompRankBadge.tsx @@ -1,11 +1,9 @@ +// CompRankBadge.tsx 'use client'; - import Image from 'next/image'; import Tooltip from './Tooltip'; -type Props = { - rank: number | null; -}; +type Props = { rank: number | null }; const rankNames: Record = { 1: 'Silver I', @@ -32,7 +30,6 @@ const rankNames: Record = { export default function CompRankBadge({ rank }: Props) { let imageName = 'skillgroup_none.webp'; let altText = 'No Rank'; - if (typeof rank === 'number') { if (rank >= 1 && rank <= 18) { imageName = `skillgroup${rank}.webp`; @@ -50,8 +47,10 @@ export default function CompRankBadge({ rank }: Props) { alt={altText} width={60} height={60} - sizes="(max-width: 768px) 100px, 70px" - style={{ objectFit: 'contain' }} // ← korrekt! + // Wichtig: feste Höhe für die Zeile, Breite auto + className="inline-block align-middle h-7 w-auto" // h-7 = 28px + sizes="70px" + style={{ objectFit: 'contain' }} /> ); diff --git a/src/app/components/InvitePlayersModal.tsx b/src/app/components/InvitePlayersModal.tsx index 99ca609..7c43bbf 100644 --- a/src/app/components/InvitePlayersModal.tsx +++ b/src/app/components/InvitePlayersModal.tsx @@ -27,6 +27,7 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir const [selectedIds, setSelectedIds] = useState([]) const [invitedIds, setInvitedIds] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [isInviting, setIsInviting] = useState(false) const [isSuccess, setIsSuccess] = useState(false) const [sentCount, setSentCount] = useState(0) const [searchTerm, setSearchTerm] = useState('') @@ -87,10 +88,12 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir } const handleInvite = async () => { + if (isInviting) return if (selectedIds.length === 0 || !steamId) return const ids = [...selectedIds] try { + setIsInviting(true) const url = directAdd ? '/api/team/add-players' : '/api/team/invite' const body = directAdd ? { teamId: team.id, steamIds: ids } @@ -135,6 +138,9 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir setSentCount(0) setIsSuccess(true) } + finally { + setIsInviting(false) + } } useEffect(() => { @@ -272,13 +278,18 @@ export default function InvitePlayersModal({ show, onClose, onSuccess, team, dir title={directAdd ? 'Spieler hinzufügen' : 'Spieler einladen'} show={show} onClose={onClose} - onSave={handleInvite} + onSave={() => { if (!isInviting) handleInvite() }} closeButtonColor={isSuccess ? 'teal' : 'blue'} closeButtonTitle={ isSuccess - ? directAdd ? 'Spieler hinzugefügt' : 'Einladungen versendet' - : directAdd ? 'Hinzufügen' : 'Einladungen senden' + ? (directAdd ? 'Spieler hinzugefügt' : 'Einladungen versendet') + : ( + isInviting + ? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...') + : (directAdd ? 'Hinzufügen' : 'Einladungen senden') + ) } + closeButtonLoading={isInviting} scrollBody={true} >

diff --git a/src/app/components/MapVoteBanner.tsx b/src/app/components/MapVoteBanner.tsx index b6632fc..21cb988 100644 --- a/src/app/components/MapVoteBanner.tsx +++ b/src/app/components/MapVoteBanner.tsx @@ -88,11 +88,11 @@ export default function MapVoteBanner({ match, initialNow }: Props) { 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 ' + + 'group relative overflow-hidden rounded-xl border bg-white/90 dark:bg-neutral-800/90 ' + + 'dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ' + (isOpen - ? 'ring-1 ring-green-500/15 hover:ring-green-500/25 hover:shadow-md' - : 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md') + ? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg' + : 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md'); return (

- {isOpen &&
} + {isOpen && ( + <> +
+ + + )}
-
+
@@ -151,6 +156,7 @@ export default function MapVoteBanner({ match, initialNow }: Props) {
diff --git a/src/app/components/MapVotePanel.tsx b/src/app/components/MapVotePanel.tsx index 638c886..553ebfa 100644 --- a/src/app/components/MapVotePanel.tsx +++ b/src/app/components/MapVotePanel.tsx @@ -1,4 +1,3 @@ -// /app/components/MapVotePanel.tsx 'use client' import { useEffect, useMemo, useState, useCallback, useRef } from 'react' @@ -7,73 +6,89 @@ import type React from 'react' import { AnimatePresence, motion } from 'framer-motion' import { useSession } from 'next-auth/react' import { useSSEStore } from '@/app/lib/useSSEStore' +import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore' import MapVoteProfileCard from './MapVoteProfileCard' -import type { Match, MatchPlayer } from '../types/match' -import type { MapVoteState } from '../types/mapvote' import TeamPremierRankBadge from './TeamPremierRankBadge' import Button from './Button' -import Image from 'next/image' import LoadingSpinner from './LoadingSpinner' +import type { Match, MatchPlayer } from '../types/match' +import type { MapVoteState } from '../types/mapvote' import { MATCH_EVENTS } from '@/app/lib/sseEvents' -type Props = { match: Match } +/* =================== Utilities & constants =================== */ -const getTeamLogo = (logo?: string | null) => - logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp' +type Props = { match: Match } const HOLD_MS = 1200 const COMPLETE_THRESHOLD = 1.0 -export default function MapVotePanel({ match }: Props) { - const { data: session } = useSession() - const mySteamId = session?.user?.steamId - const { lastEvent } = useSSEStore() - const router = useRouter() +const getTeamLogo = (logo?: string | null) => + logo ? `/assets/img/logos/${logo}` : '/assets/img/logos/cs2.webp' +const fmtCountdown = (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)}` +} + +/* =================== Component =================== */ + +export default function MapVotePanel({ match }: Props) { + /* -------- External stores / env -------- */ + const router = useRouter() + const { data: session } = useSession() + const { lastEvent } = useSSEStore() + const { open: overlayOpen, data: overlayData, showWithDelay } = useReadyOverlayStore() + + /* -------- Local state -------- */ const [state, setState] = useState(null) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [adminEditMode, setAdminEditMode] = useState(false) + const [overlayShownOnce, setOverlayShownOnce] = useState(false) - // --- Zeitpunkt: 1h vor Match-/Demo-Beginn (Fallback) --- + /* -------- Timers / open window -------- */ const opensAtTs = useMemo(() => { const base = new Date(match.matchDate ?? match.demoDate ?? Date.now()) return base.getTime() - 60 * 60 * 1000 }, [match.matchDate, match.demoDate]) - // „Jetzt offen“-Trigger const [nowTs, setNowTs] = useState(() => Date.now()) - useEffect(() => { const t = setInterval(() => setNowTs(Date.now()), 1000) return () => clearInterval(t) }, []) - const isOpenFromMatch = nowTs >= opensAtTs + /* -------- Overlay integration -------- */ + const overlayIsForThisMatch = overlayData?.matchId === match.id - // --- Rollen --- - const me = session?.user - const isAdmin = !!me?.isAdmin - const isLeaderA = !!me?.steamId && match.teamA?.leader?.steamId === me.steamId - const isLeaderB = !!me?.steamId && match.teamB?.leader?.steamId === me.steamId + // Merken: Overlay wurde für dieses Match mindestens einmal angezeigt + useEffect(() => { + if (overlayOpen && overlayIsForThisMatch) setOverlayShownOnce(true) + }, [overlayOpen, overlayIsForThisMatch]) - // Admin-Freeze ableiten - const adminEditingBy = state?.adminEdit?.by ?? null - const adminEditingEnabled = !!state?.adminEdit?.enabled - const isFrozenByAdmin = adminEditingEnabled && adminEditingBy !== me?.steamId + // Auf „match-ready“ reagieren → Overlay nach 3s öffnen + useEffect(() => { + if (!lastEvent) return + if (lastEvent.type !== 'match-ready') return + if (lastEvent.payload?.matchId !== match.id) return - const canActForTeamId = useCallback( - (teamId?: string | null) => { - if (!teamId) return false - return ( - (teamId === match.teamA?.id && isLeaderA) || - (teamId === match.teamB?.id && isLeaderB) - ) - }, - [isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id], - ) + const fm = lastEvent.payload?.firstMap ?? {} + showWithDelay( + { + matchId: match.id, + mapLabel: fm?.label ?? 'Erste Map', + mapBg: fm?.bg ?? '/assets/img/maps/cs2.webp', + }, + 3000 + ) + }, [lastEvent, match.id, showWithDelay]) - // --- Laden / Reload --- + /* -------- Data load (initial + SSE refresh) -------- */ const load = useCallback(async () => { setIsLoading(true) setError(null) @@ -84,9 +99,7 @@ export default function MapVotePanel({ match }: Props) { 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)') - } + if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)') setState(json) } catch (e: any) { setState(null) @@ -98,32 +111,46 @@ export default function MapVotePanel({ match }: Props) { useEffect(() => { load() }, [load]) - // --- SSE: live nachladen --- useEffect(() => { if (!lastEvent) return if (!MATCH_EVENTS.has(lastEvent.type)) return - - const matchId = lastEvent.payload?.matchId - if (matchId !== match.id) return - + if (lastEvent.payload?.matchId !== match.id) return load() }, [lastEvent, match.id, load]) - // --- Admin-Edit lokalen Toggle an globalem Zustand spiegeln --- + /* -------- Admin-Edit Mirror -------- */ + const adminEditingBy = state?.adminEdit?.by ?? null + const adminEditingEnabled = !!state?.adminEdit?.enabled useEffect(() => { - const iAmEditing = adminEditingEnabled && adminEditingBy === me?.steamId + const iAmEditing = adminEditingEnabled && adminEditingBy === session?.user?.steamId setAdminEditMode(iAmEditing) - }, [adminEditingEnabled, adminEditingBy, me?.steamId]) + }, [adminEditingEnabled, adminEditingBy, session?.user?.steamId]) - // --- Abgeleitet --- + /* -------- Derived flags & memoized maps -------- */ const opensAt = useMemo( () => (state?.opensAt ? new Date(state.opensAt).getTime() : null), - [state?.opensAt], + [state?.opensAt] ) + const isOpenFromMatch = nowTs >= opensAtTs const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch const msToOpen = Math.max((opensAt ?? opensAtTs) - nowTs, 0) + const me = session?.user + const isAdmin = !!me?.isAdmin + const mySteamId = me?.steamId + const isLeaderA = !!mySteamId && match.teamA?.leader?.steamId === mySteamId + const isLeaderB = !!mySteamId && match.teamB?.leader?.steamId === mySteamId + + const isFrozenByAdmin = adminEditingEnabled && adminEditingBy !== mySteamId + const currentStep = state?.steps?.[state?.currentIndex ?? 0] + const canActForTeamId = useCallback( + (teamId?: string | null) => + !!teamId && + ((teamId === match.teamA?.id && isLeaderA) || + (teamId === match.teamB?.id && isLeaderB)), + [isLeaderA, isLeaderB, match.teamA?.id, match.teamB?.id] + ) const isMyTurn = Boolean( isOpen && !state?.locked && @@ -132,7 +159,6 @@ export default function MapVotePanel({ match }: Props) { (canActForTeamId(currentStep.teamId) || (isAdmin && adminEditMode)) ) - // Map -> (action, teamId) wenn bereits entschieden const decisionByMap = useMemo(() => { const map = new Map() for (const s of state?.steps ?? []) { @@ -143,7 +169,28 @@ export default function MapVotePanel({ match }: Props) { const fmt = (k: string) => state?.mapVisuals?.[k]?.label ?? k - // --- Aktionen --- + // Statusleiste (locked): Spinner solange Overlay noch nicht (für dieses Match) gezeigt, dann ✅ + const showDone = !!state?.locked && (overlayShownOnce || !overlayIsForThisMatch) + const showLockedSpinner = !!state?.locked && !showDone + + // Page-level Loading/Error + const showPageLoading = isLoading && !state + const showError = !!error && !state + + /* -------- Admin actions -------- */ + async function postAdminEdit(enabled: boolean) { + const r = await fetch(`/api/matches/${match.id}/mapvote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adminEdit: enabled }), + }) + if (!r.ok) { + const j = await r.json().catch(() => ({})) + throw new Error(j?.message || 'Konnte Admin-Edit nicht setzen') + } + return r.json() + } + const handlePickOrBan = async (map: string) => { if (!isMyTurn || !currentStep) return try { @@ -157,7 +204,6 @@ export default function MapVotePanel({ match }: Props) { alert(j.message ?? 'Aktion fehlgeschlagen') return } - // Optimistisches Update setState(prev => prev @@ -174,21 +220,7 @@ export default function MapVotePanel({ match }: Props) { } } - // --- Admin-Edit global toggeln --- - async function postAdminEdit(enabled: boolean) { - const r = await fetch(`/api/matches/${match.id}/mapvote`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ adminEdit: enabled }), - }) - if (!r.ok) { - const j = await r.json().catch(() => ({})) - throw new Error(j?.message || 'Konnte Admin-Edit nicht setzen') - } - return r.json() - } - - // --- Press-and-hold Logik (pro Map) --- + /* -------- Press-and-hold logic -------- */ const rafRef = useRef(null) const holdStartRef = useRef(null) const holdMapRef = useRef(null) @@ -209,7 +241,7 @@ export default function MapVotePanel({ match }: Props) { submittedRef.current = true setTimeout(() => handlePickOrBan(map), 10) }, - [handlePickOrBan], + [handlePickOrBan] ) const stepHold = useCallback( @@ -218,9 +250,7 @@ export default function MapVotePanel({ match }: Props) { const elapsed = ts - holdStartRef.current const p = Math.min(1, elapsed / HOLD_MS) const map = holdMapRef.current - - setProgressByMap((prev) => ({ ...prev, [map]: p })) - + setProgressByMap(prev => ({ ...prev, [map]: p })) if (p >= COMPLETE_THRESHOLD) { const doneMap = map resetHold() @@ -229,37 +259,41 @@ export default function MapVotePanel({ match }: Props) { } rafRef.current = requestAnimationFrame(stepHold) }, - [resetHold, finishAndSubmit], + [resetHold, finishAndSubmit] ) - const onHoldStart = useCallback( - (map: string, allowed: boolean) => { - if (!allowed) return + const onHoldStart = useCallback((map: string, allowed: boolean) => { + if (!allowed) return + resetHold() + holdMapRef.current = map + holdStartRef.current = performance.now() + setProgressByMap(prev => ({ ...prev, [map]: 0 })) + rafRef.current = requestAnimationFrame(stepHold) + }, [stepHold, resetHold]) + + const cancelOrSubmitIfComplete = useCallback((map: string) => { + const p = progressByMap[map] ?? 0 + if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) { resetHold() - holdMapRef.current = map - holdStartRef.current = performance.now() - setProgressByMap((prev) => ({ ...prev, [map]: 0 })) - rafRef.current = requestAnimationFrame(stepHold) - }, - [stepHold, resetHold], - ) + finishAndSubmit(map) + return + } + if (holdMapRef.current === map) { + resetHold() + setProgressByMap(prev => ({ ...prev, [map]: 0 })) + } + }, [progressByMap, resetHold, finishAndSubmit]) - const cancelOrSubmitIfComplete = useCallback( - (map: string) => { - const p = progressByMap[map] ?? 0 - if (holdMapRef.current === map && p >= COMPLETE_THRESHOLD && !submittedRef.current) { - resetHold() - finishAndSubmit(map) - return - } - if (holdMapRef.current === map) { - resetHold() - setProgressByMap((prev) => ({ ...prev, [map]: 0 })) - } - }, - [progressByMap, resetHold, finishAndSubmit], - ) + const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => { + e.preventDefault() + onHoldStart(map, allowed) + } + const onTouchEnd = (map: string) => (e: React.TouchEvent) => { + e.preventDefault() + cancelOrSubmitIfComplete(map) + } + /* -------- Decider chooser -------- */ const deciderChooserTeamId = useMemo(() => { const steps = state?.steps ?? [] const decIdx = steps.findIndex(s => s.action === 'decider') @@ -271,36 +305,22 @@ export default function MapVotePanel({ match }: Props) { return null }, [state?.steps]) - // Touch-Unterstützung - const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => { - e.preventDefault() - onHoldStart(map, allowed) - } - const onTouchEnd = (map: string) => (e: React.TouchEvent) => { - e.preventDefault() - cancelOrSubmitIfComplete(map) - } - - // --- Spielerlisten ableiten (Hooks bleiben IMMER aktiv) --- + /* -------- Players & ranks -------- */ const playersA = useMemo(() => { const teamPlayers = (match as any)?.teamA?.players as MatchPlayer[] | undefined if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers - const all = (match as any).players as MatchPlayer[] | undefined const teamAUsers = (match as any).teamAUsers as { steamId: string }[] | undefined if (Array.isArray(all) && Array.isArray(teamAUsers) && teamAUsers.length) { const setA = new Set(teamAUsers.map(u => u.steamId)) return all.filter(p => setA.has(p.user.steamId)) } - if (Array.isArray(all) && match.teamA?.id) { return all.filter(p => (p as any).team?.id === match.teamA?.id) } - const votePlayers = state?.teams?.teamA?.players as | Array<{ steamId: string; name?: string | null; avatar?: string | null }> | undefined - if (Array.isArray(votePlayers) && votePlayers.length) { return votePlayers.map((p): MatchPlayer => ({ user: { @@ -311,29 +331,24 @@ export default function MapVotePanel({ match }: Props) { stats: undefined, })) } - return [] }, [match, state?.teams?.teamA?.players]) const playersB = useMemo(() => { const teamPlayers = (match as any)?.teamB?.players as MatchPlayer[] | undefined if (Array.isArray(teamPlayers) && teamPlayers.length) return teamPlayers - const all = (match as any).players as MatchPlayer[] | undefined const teamBUsers = (match as any).teamBUsers as { steamId: string }[] | undefined if (Array.isArray(all) && Array.isArray(teamBUsers) && teamBUsers.length) { const setB = new Set(teamBUsers.map(u => u.steamId)) return all.filter(p => setB.has(p.user.steamId)) } - if (Array.isArray(all) && match.teamB?.id) { return all.filter(p => (p as any).team?.id === match.teamB?.id) } - const votePlayers = state?.teams?.teamB?.players as | Array<{ steamId: string; name?: string | null; avatar?: string | null }> | undefined - if (Array.isArray(votePlayers) && votePlayers.length) { return votePlayers.map((p): MatchPlayer => ({ user: { @@ -344,60 +359,32 @@ export default function MapVotePanel({ match }: Props) { stats: undefined, })) } - return [] }, [match, state?.teams?.teamB?.players]) - const teamAPlayersForRank = useMemo( () => playersA.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any, [playersA] ) - const teamBPlayersForRank = useMemo( () => playersB.map(p => ({ premierRank: p.stats?.rankNew ?? 0 })) as any, [playersB] ) - // --- kleine Helpers --- - const editingDisplayName = useMemo(() => { - if (!adminEditingBy) return null - const all: Array<{ steamId: string; name?: string | null }> = [] - const pushMaybe = (x: any) => { if (x?.steamId) all.push({ steamId: x.steamId, name: x.name }) } - pushMaybe(state?.teams?.teamA?.leader) - pushMaybe(state?.teams?.teamB?.leader) - ;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe) - ;(state?.teams?.teamB?.players ?? []).forEach(pushMaybe) - return all.find(p => p.steamId === adminEditingBy)?.name || 'Admin' - }, [adminEditingBy, state?.teams]) - - const showLoading = isLoading && !state - const showError = !!error && !state - - const sortedMapPool = useMemo(() => { - // nach Anzeige-Label sortieren (fallback: key), case-insensitive, deutsch - return [...(state?.mapPool ?? [])].sort((a, b) => - (state?.mapVisuals?.[a]?.label ?? a) - .localeCompare(state?.mapVisuals?.[b]?.label ?? b, 'de', { sensitivity: 'base' }) - ) - }, [state?.mapPool, state?.mapVisuals]) - - + /* -------- Left/Right selection -------- */ let teamLeftKey: 'teamA' | 'teamB' = 'teamA' let teamRightKey: 'teamA' | 'teamB' = 'teamB' - - // Prüfen, ob ich in playersB bin if (mySteamId && playersB.some(p => p.user?.steamId === mySteamId)) { teamLeftKey = 'teamB' teamRightKey = 'teamA' } - let teamLeft = match[teamLeftKey] - let teamRight = match[teamRightKey] - let playersLeft = teamLeftKey === 'teamA' ? playersA : playersB - let playersRight = teamRightKey === 'teamA' ? playersA : playersB - let rankLeft = teamLeftKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank - let rankRight = teamRightKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank + const teamLeft = match[teamLeftKey] + const teamRight = match[teamRightKey] + const playersLeft = teamLeftKey === 'teamA' ? playersA : playersB + const playersRight = teamRightKey === 'teamA' ? playersA : playersB + const rankLeft = teamLeftKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank + const rankRight = teamRightKey === 'teamA' ? teamAPlayersForRank : teamBPlayersForRank const leftIsActiveTurn = !!currentStep?.teamId && @@ -409,39 +396,36 @@ export default function MapVotePanel({ match }: Props) { currentStep.teamId === (state?.teams?.[teamRightKey]?.id ?? teamRight?.id) && !state?.locked - // --- UI --- + /* -------- Sorted map pool -------- */ + const sortedMapPool = useMemo(() => { + return [...(state?.mapPool ?? [])].sort((a, b) => + (state?.mapVisuals?.[a]?.label ?? a) + .localeCompare(state?.mapVisuals?.[b]?.label ?? b, 'de', { sensitivity: 'base' }) + ) + }, [state?.mapPool, state?.mapVisuals]) + + /* =================== Render =================== */ + return (
- {showLoading ? ( -
- -
+ {showPageLoading ? ( +
) : showError ? (
{error}
) : ( <> {/* Header */}
- {/* Linke Spalte */}
-
- - {/* Mittlere Spalte (zentriert) */} +

Voting

- - {/* Rechte Spalte */} +
-
- Modus: BO{match.bestOf ?? state?.bestOf ?? 3} -
+
Modus: BO{match.bestOf ?? state?.bestOf ?? 3}
{isAdmin && isOpen && ( <> @@ -452,14 +436,13 @@ export default function MapVotePanel({ match }: Props) { className="ml-2" title={adminEditMode ? 'Admin-Bearbeitung beenden' : 'Map-Vote als Admin bearbeiten'} onClick={async () => { - // Optimistisch lokal toggeln const next = !adminEditMode setAdminEditMode(next) try { - await postAdminEdit(next) // globaler Freeze on/off + await postAdminEdit(next) await load() } catch (e: any) { - setAdminEditMode(v => !v) // rollback + setAdminEditMode(v => !v) alert(e?.message ?? 'Fehler beim Umschalten des Admin-Edits') } }} @@ -504,15 +487,31 @@ export default function MapVotePanel({ match }: Props) {
{state?.locked ? ( - ✅ Voting abgeschlossen + {showLockedSpinner ? ( + <> + + Match wird geladen…. + + ) : ( + <>✅ Voting abgeschlossen + )} ) : isOpen ? ( isFrozenByAdmin ? ( 🔒 Admin-Edit aktiv – Voting pausiert - {editingDisplayName ? ` (von ${editingDisplayName})` : ''} + {(() => { + const all: Array<{ steamId: string; name?: string | null }> = [] + const pushMaybe = (x: any) => { if (x?.steamId) all.push({ steamId: x.steamId, name: x.name }) } + pushMaybe(state?.teams?.teamA?.leader) + pushMaybe(state?.teams?.teamB?.leader) + ;(state?.teams?.teamA?.players ?? []).forEach(pushMaybe) + ;(state?.teams?.teamB?.players ?? []).forEach(pushMaybe) + const name = all.find(p => p.steamId === adminEditingBy)?.name || 'Admin' + return adminEditingBy ? ` (von ${name})` : '' + })()} - ) : isMyTurn ? ( + ) : ( {currentStep?.action === 'ban' ? '🚫 Dein Team darf bannen' @@ -520,20 +519,15 @@ export default function MapVotePanel({ match }: Props) { ? '✅ Dein Team darf picken' : 'Du bist dran'} - ) : ( - - ⏳ Wartet auf  - {currentStep?.teamId === teamLeft?.id ? teamLeft?.name : teamRight?.name} - ) ) : ( - Öffnet in {formatCountdown(msToOpen)} + Öffnet in {fmtCountdown(msToOpen)} )}
-
{/* rechter Leer-Slot */} +
{error && ( @@ -543,34 +537,22 @@ export default function MapVotePanel({ match }: Props) { )}
- + {/* Hauptbereich */} {state && (
{/* Linke Spalte */} -
- {/* Teamkopf */} +
- {teamLeft?.name + {teamLeft?.name
{teamLeft?.name ?? 'Team'}
- {/* Spieler */} {playersLeft.map((p: MatchPlayer) => ( router.push(`/profile/${p.user.steamId}`)} - isLeader={ - (state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === - p.user.steamId - } - isActiveTurn={ - !!currentStep?.teamId && - currentStep.teamId === (state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id) && - !state.locked - } + isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId} + isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id) && !state.locked} /> ))}
@@ -599,13 +574,12 @@ export default function MapVotePanel({ match }: Props) {
    {sortedMapPool.map((map) => { const decision = decisionByMap.get(map) - const status = decision?.action ?? null // 'ban' | 'pick' | 'decider' | null + const status = decision?.action ?? null const teamId = decision?.teamId ?? null const taken = !!status const isAvailable = !taken && isMyTurn && isOpen && !state?.locked - // Intent-Farben basierend auf aktuellem Step const intent = isAvailable ? currentStep?.action : null const intentStyles = intent === 'ban' @@ -614,78 +588,44 @@ export default function MapVotePanel({ match }: Props) { ? { hover: 'hover:bg-green-50 dark:hover:bg-green-950', progress: 'bg-green-200/60 dark:bg-green-800/40' } : { hover: 'hover:bg-blue-50 dark:hover:bg-blue-950', progress: 'bg-blue-200/60 dark:bg-blue-800/40' } - const baseClasses = - 'relative flex items-center justify-between gap-2 rounded-md border border-neutral-500 p-2.5 transition select-none' - + const baseClasses = 'relative flex items-center justify-between gap-2 rounded-md border border-neutral-500 p-2.5 transition select-none' const visualTaken = status === 'ban' ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-900/40 text-red-800 dark:text-red-200' : status === 'pick' || status === 'decider' ? 'bg-blue-50/60 dark:bg-blue-900/20 border-blue-200 dark:border-blue-900/40' : 'bg-neutral-100 dark:bg-neutral-800 border-neutral-300 dark:border-neutral-700' - const visualAvailable = `bg-white dark:bg-neutral-900 ${intentStyles.hover} cursor-pointer` const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}` const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled - // Decider-Team bestimmen (falls nötig) const effectiveTeamId = - status === 'decider' - ? deciderChooserTeamId - : decision?.teamId ?? null + status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null - const pickedByLeft = - (status === 'pick' || status === 'decider') && effectiveTeamId === teamLeft?.id - const pickedByRight = - (status === 'pick' || status === 'decider') && effectiveTeamId === teamRight?.id + const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === teamLeft?.id + const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === teamRight?.id const progress = progressByMap[map] ?? 0 const showProgress = isAvailable && progress > 0 && progress < 1 const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg` - - const disabledTitle = isFrozenByAdmin - ? 'Ein Admin bearbeitet gerade – Voting gesperrt' - : 'Nur der Team-Leader (oder Admin) darf wählen' + const disabledTitle = isFrozenByAdmin ? 'Ein Admin bearbeitet gerade – Voting gesperrt' : 'Nur der Team-Leader (oder Admin) darf wählen' return ( -
  • - {/* linker Slot */} +
  • {pickedByLeft ? ( - {teamLeft?.name - ) : ( -
    - )} + {teamLeft?.name + ) :
    } - {/* Button */} - {/* rechter Slot */} {pickedByRight ? ( - {teamRight?.name - ) : ( -
    - )} + {teamRight?.name + ) :
    }
  • ) })} @@ -797,29 +680,17 @@ export default function MapVotePanel({ match }: Props) { {/* Rechte Spalte */} -
    - {/* Teamkopf B */} +
    {teamRight?.name ?? 'Team'}
    - {teamRight?.name + {teamRight?.name
    - {/* Spieler */} {playersRight.map((p: MatchPlayer) => ( router.push(`/profile/${p.user.steamId}`)} - isLeader={ - (state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === - p.user.steamId - } - isActiveTurn={ - !!currentStep?.teamId && - currentStep.teamId === (state?.teams?.[teamRightKey]?.id ?? teamRight?.id) && - !state.locked - } + isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId} + isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamRightKey]?.id ?? teamRight?.id) && !state.locked} /> ))}
    )} - + {/* Footer */} {state && isOpen && (

    Gewählte Maps

    - {(() => { const bestOf = match.bestOf ?? state.bestOf ?? 3 const cols = bestOf === 5 ? 5 : 3 - - // nur Picks + Decider in Anzeige-Reihenfolge const chosenSteps = (state.steps ?? []).filter( - (s) => (s.action === 'pick' || s.action === 'decider') && s.map + s => (s.action === 'pick' || s.action === 'decider') && s.map ) - - // Team, das vor dem Decider zuletzt gebannt hat (Chooser) - const decIdx = (state.steps ?? []).findIndex((s) => s.action === 'decider') + const decIdx = (state.steps ?? []).findIndex(s => s.action === 'decider') let chooserTeamId: string | null = null if (decIdx >= 0) { for (let i = decIdx - 1; i >= 0; i--) { @@ -868,44 +727,28 @@ export default function MapVotePanel({ match }: Props) { if (s.action === 'ban' && s.teamId) { chooserTeamId = s.teamId; break } } } - - const fmt = (k: string) => state?.mapVisuals?.[k]?.label ?? k const teamLeftLogo = getTeamLogo(teamLeft?.logo) const teamRightLogo = getTeamLogo(teamRight?.logo) return ( -
    +
    {Array.from({ length: cols }).map((_, idx) => { const step = chosenSteps[idx] const action = step?.action as ('pick' | 'decider') | undefined const mapKey = step?.map const label = mapKey ? fmt(mapKey) : '?' - const bg = mapKey - ? (state.mapVisuals?.[mapKey]?.bg ?? `/assets/img/maps/${mapKey}/1.jpg`) - : null - - // Logo neben der Pill - const pickTeamId = - action === 'pick' ? (step?.teamId ?? null) - : action === 'decider' ? chooserTeamId - : null - + const bg = mapKey ? (state.mapVisuals?.[mapKey]?.bg ?? `/assets/img/maps/${mapKey}/1.jpg`) : null + const pickTeamId = action === 'pick' ? (step?.teamId ?? null) + : action === 'decider' ? chooserTeamId + : null const pickedByLeft = pickTeamId && pickTeamId === teamLeft?.id const pickedByRight = pickTeamId && pickTeamId === teamRight?.id - const sideLogo = - pickedByLeft ? teamLeftLogo - : pickedByRight ? teamRightLogo - : null - + const sideLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null const frameClasses = - action === 'pick' - ? 'ring-2 ring-green-500' - : action === 'decider' - ? 'ring-2 ring-blue-500' - : 'ring-1 ring-neutral-300 dark:ring-neutral-700' + action === 'pick' ? 'ring-2 ring-green-500' + : action === 'decider' ? 'ring-2 ring-blue-500' + : 'ring-1 ring-neutral-300 dark:ring-neutral-700' return ( - {bg && ( - {label} - )} + {bg && ({label})}
    -
    - {/* etwas kleinere Typo */} - + {label} - {sideLogo && ( - Team + Team )}
    @@ -963,13 +788,3 @@ export default function MapVotePanel({ match }: Props) {
    ) } - -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)}` -} diff --git a/src/app/components/MatchDetails.tsx b/src/app/components/MatchDetails.tsx index 67ba1df..5ae3028 100644 --- a/src/app/components/MatchDetails.tsx +++ b/src/app/components/MatchDetails.tsx @@ -28,6 +28,7 @@ import { Team } from '../types/team' import Alert from './Alert' import Image from 'next/image' import { MATCH_EVENTS } from '../lib/sseEvents' +import Link from 'next/link' type TeamWithPlayers = Team & { players?: MatchPlayer[] } @@ -246,21 +247,38 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: /* ─── Render ─────────────────────────────────────────────── */ return (
    + {/* Kopfzeile: Zurück + Admin-Buttons */} +
    + {/* Links: Zurück */} + + + + + {/* Rechts: Admin-Buttons */} + {isAdmin && ( +
    + + +
    + )} +
    +

    Match auf {mapLabel} ({match.matchType})

    - {isAdmin && ( -
    - - -
    - )} -

    Datum: {readableDate}

    @@ -280,20 +298,50 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:

    {match.teamA?.name ?? 'Team A'}

    - {showEditA && ( - - - Du kannst die Aufstellung noch bis {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten. + +
    + {showEditA ? ( + <> + {/* Unlocked-Icon */} + + + + + ) : ( + <> + {/* Locked-Icon */} + + + + + ) + } + + + {showEditA ? ( + <> + Du kannst die Aufstellung noch bis{' '} + {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten. + + ) : ( + <>Die Aufstellung kann nicht mehr bearbeitet werden. + )} - - - )} +
    + + +
    {renderTable(teamAPlayers)} @@ -322,20 +370,51 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: {match.teamB?.name ?? 'Team B'} - {showEditB && ( - - - Du kannst die Aufstellung noch bis {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten. + + +
    + {showEditB ? ( + <> + {/* Unlocked-Icon */} + + + + + ) : ( + <> + {/* Locked-Icon */} + + + + + ) + } + + + {showEditB ? ( + <> + Du kannst die Aufstellung noch bis{' '} + {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten. + + ) : ( + <>Die Aufstellung kann nicht mehr bearbeitet werden. + )} - - - )} +
    + + +
    {renderTable(teamBPlayers)} diff --git a/src/app/components/Modal.tsx b/src/app/components/Modal.tsx index a1b7f87..421cd8a 100644 --- a/src/app/components/Modal.tsx +++ b/src/app/components/Modal.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect } from 'react' +import Button from './Button' type Width = | 'sm:max-w-sm' @@ -20,6 +21,7 @@ type ModalProps = { hideCloseButton?: boolean closeButtonColor?: 'blue' | 'red' | 'green' | 'teal' closeButtonTitle?: string + closeButtonLoading?: boolean disableSave?: boolean maxWidth?: Width /** Wenn false, wird der Body nicht gescrollt (wir paginieren stattdessen) */ @@ -119,8 +121,8 @@ export default function Modal({ {!hideCloseButton && ( - + )}
    @@ -156,25 +158,25 @@ export default function Modal({ {/* Footer (fixe Höhe) */}
    {!hideCloseButton && ( - + )} {onSave && ( - + )}
    diff --git a/src/app/components/Table.tsx b/src/app/components/Table.tsx index d6029b4..322a4f4 100644 --- a/src/app/components/Table.tsx +++ b/src/app/components/Table.tsx @@ -70,7 +70,7 @@ function Cell({ const baseClass = Component === 'th' ? 'px-6 py-3 text-start font-medium text-xs text-gray-500 uppercase dark:text-neutral-400' - : 'px-6 py-3 whitespace-nowrap text-sm text-gray-800 dark:text-neutral-200' + : 'px-6 py-3 text-sm text-gray-800 dark:text-neutral-200' return ( {children} diff --git a/src/app/components/TeamInvitationBanner.tsx b/src/app/components/TeamInvitationBanner.tsx index e451e7c..583ad0f 100644 --- a/src/app/components/TeamInvitationBanner.tsx +++ b/src/app/components/TeamInvitationBanner.tsx @@ -144,36 +144,65 @@ export default function TeamInvitationBanner({
diff --git a/src/app/team/page.tsx b/src/app/team/page.tsx index 34b51db..3d8ed62 100644 --- a/src/app/team/page.tsx +++ b/src/app/team/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import TeamCardComponent from "../components/TeamCardComponent"; import Card from "../components/Card"; +import LoadingSpinner from "../components/LoadingSpinner"; type TeamsResponse = { teams?: any[] } | undefined; type InvitesResponse = { invitations?: any[] } | undefined; @@ -57,7 +58,11 @@ export default function TeamPageClient() { }, []); if (loading) { - return

Lade Teams …

; + return ( +

+ +

+ ); } return ( diff --git a/src/generated/prisma/edge.js b/src/generated/prisma/edge.js index c54ba13..ae0f377 100644 --- a/src/generated/prisma/edge.js +++ b/src/generated/prisma/edge.js @@ -346,7 +346,7 @@ const config = { "value": "prisma-client-js" }, "output": { - "value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", + "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma", "fromEnvVar": null }, "config": { @@ -360,7 +360,7 @@ const config = { } ], "previewFeatures": [], - "sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", + "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma", "isCustomOutput": true }, "relativeEnvPaths": { @@ -374,6 +374,7 @@ const config = { "db" ], "activeProvider": "postgresql", + "postinstall": false, "inlineDatasources": { "db": { "url": { diff --git a/src/generated/prisma/index.js b/src/generated/prisma/index.js index a9c4901..8dc6113 100644 --- a/src/generated/prisma/index.js +++ b/src/generated/prisma/index.js @@ -347,7 +347,7 @@ const config = { "value": "prisma-client-js" }, "output": { - "value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma", + "value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma", "fromEnvVar": null }, "config": { @@ -361,7 +361,7 @@ const config = { } ], "previewFeatures": [], - "sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma", + "sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma", "isCustomOutput": true }, "relativeEnvPaths": { @@ -375,6 +375,7 @@ const config = { "db" ], "activeProvider": "postgresql", + "postinstall": false, "inlineDatasources": { "db": { "url": {