This commit is contained in:
Linrador 2025-10-14 23:25:20 +02:00
parent 15d369b76f
commit 99ad158526
4 changed files with 229 additions and 129 deletions

View File

@ -140,6 +140,12 @@ export default function MapVotePanel({ match }: Props) {
const { lastEvent } = useSSEStore()
const { open: overlayOpen, data: overlayData, showWithDelay } = useReadyOverlayStore()
const didInitRef = useRef(false); // StrictMode-Guard (nur einmal initial load)
const inFlightRef = useRef(false); // verhindert parallele Loads
const lastLoadAtRef = useRef(0); // Throttle
const queuedRef = useRef(false); // falls während In-Flight weitere Reloads ankommen
const abortRef = useRef<AbortController | null>(null);
/* -------- Local state -------- */
const [state, setState] = useState<MapVoteState | null>(null)
const [isLoading, setIsLoading] = useState(false)
@ -214,47 +220,108 @@ export default function MapVotePanel({ match }: Props) {
}, [lastEvent, match.id, showWithDelay]);
/* -------- Data load (initial + SSE refresh) -------- */
const load = useCallback(async () => {
setIsLoading(true)
setError(null)
const doLoad = useCallback(async () => {
// alte Anfrage abbrechen
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setIsLoading(true);
setError(null);
try {
const r = await fetch(`/api/matches/${match.id}/mapvote`, { cache: 'no-store' })
const r = await fetch(`/api/matches/${match.id}/mapvote`, {
cache: 'no-store',
signal: ac.signal,
});
if (!r.ok) {
const j = await r.json().catch(() => ({} as { message?: string }))
throw new Error(j?.message || 'Laden fehlgeschlagen')
// versuch eine brauchbare Fehlermeldung
let msg = 'Laden fehlgeschlagen';
try { msg = (await r.json())?.message ?? msg; } catch {}
throw new Error(msg);
}
const json = await r.json()
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
setState(json)
} catch (e: unknown) {
setState(null)
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
const json = await r.json();
if (!json || !Array.isArray(json.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)');
setState(json);
} catch (e) {
if ((e as any)?.name === 'AbortError') return; // wurde abgebrochen -> still
setState(null);
setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
} finally {
setIsLoading(false)
if (!ac.signal.aborted) setIsLoading(false);
}
}, [match.id])
}, [match.id]);
useEffect(() => { load() }, [load])
// NEU: gedrosselter Scheduler
const scheduleReload = useCallback(() => {
const now = Date.now();
/* -------- Admin-Edit Mirror -------- */
// wenn gerade ein doLoad läuft -> einmalig nachziehen
if (inFlightRef.current) { queuedRef.current = true; return; }
// Throttle: min. 500ms Abstand
if (now - lastLoadAtRef.current < 500) {
queuedRef.current = true;
// kurze Verzögerung, dann nochmal versuchen
window.setTimeout(() => scheduleReload(), 520 - (now - lastLoadAtRef.current));
return;
}
inFlightRef.current = true;
lastLoadAtRef.current = now;
Promise.resolve()
.then(() => doLoad())
.finally(() => {
inFlightRef.current = false;
if (queuedRef.current) {
queuedRef.current = false;
scheduleReload(); // direkt den nächsten geplanten Reload ausführen
}
});
}, [doLoad]);
// INITIALER LOAD: StrictMode-Guard (nur ein Mal)
useEffect(() => {
if (didInitRef.current) return;
didInitRef.current = true;
scheduleReload();
}, [scheduleReload]);
/* -------- Admin-Edit Mirror (sticky) -------- */
const adminEditingBy = state?.adminEdit?.by ?? null
const adminEditingEnabled = !!state?.adminEdit?.enabled
useEffect(() => {
const iAmEditing = adminEditingEnabled && adminEditingBy === session?.user?.steamId
setAdminEditMode(iAmEditing)
}, [adminEditingEnabled, adminEditingBy, session?.user?.steamId])
const serverEnabled = !!state?.adminEdit?.enabled;
const serverBy = state?.adminEdit?.by ?? null;
const iAmServerEditor = serverEnabled && serverBy === session?.user?.steamId;
// Server bestätigt mich -> AN
if (iAmServerEditor && !adminEditMode) setAdminEditMode(true);
// Server sagt AUS -> AUS
if (!serverEnabled && adminEditMode) setAdminEditMode(false);
// Sonst: lokalen Zustand NICHT überschreiben (kleben lassen)
}, [state?.adminEdit?.enabled, state?.adminEdit?.by, session?.user?.steamId, adminEditMode]);
/* -------- Derived flags & memoized maps -------- */
const me = session?.user
const isAdmin = !!me?.isAdmin
const mySteamId = me?.steamId
// Klebriges Flag: ich editiere (lokal oder lt. Server)
const editingAsMe =
adminEditMode || (adminEditingEnabled && adminEditingBy === mySteamId)
// Voting einfrieren, wenn ein ANDERER Admin editiert
const isFrozenByAdmin = adminEditingEnabled && adminEditingBy !== mySteamId
const leaderAId = state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId ?? null
const leaderBId = state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId ?? null
const isLeaderA = !!mySteamId && leaderAId === mySteamId
const isLeaderB = !!mySteamId && leaderBId === mySteamId
const isFrozenByAdmin = adminEditingEnabled && adminEditingBy !== mySteamId
const currentStep = state?.steps?.[state?.currentIndex ?? 0]
const canActForTeamId = useCallback(
(teamId?: string | null) =>
@ -268,7 +335,7 @@ export default function MapVotePanel({ match }: Props) {
!state?.locked &&
!isFrozenByAdmin &&
currentStep?.teamId &&
(canActForTeamId(currentStep.teamId) || (isAdmin && adminEditMode))
(canActForTeamId(currentStep.teamId) || (isAdmin && editingAsMe))
)
const decisionByMap = useMemo(() => {
@ -340,6 +407,7 @@ export default function MapVotePanel({ match }: Props) {
const holdMapRef = useRef<string | null>(null)
const submittedRef = useRef<boolean>(false)
const [progressByMap, setProgressByMap] = useState<Record<string, number>>({})
const lastEvtKeyRef = useRef<string>(''); // Dedupe gleicher Events
const resetHold = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
@ -711,9 +779,20 @@ export default function MapVotePanel({ match }: Props) {
const shouldRefresh = isRefreshEvent(type);
if (shouldRefresh) {
load();
// einfache Dedupe: gleicher Event-Key in kurzer Folge -> ignorieren
const evtKey = [
type,
evtMatchId ?? '',
evtTeamId ?? '',
actionType ?? '',
actionData ?? '',
].join('|');
if (evtKey === lastEvtKeyRef.current) return;
lastEvtKeyRef.current = evtKey;
scheduleReload(); // <— statt load()
}
}, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, load, matchBaseTs, teamSteamIds, applyLeaderChange]);
}, [lastEvent, match.id, match.teamA?.id, match.teamB?.id, matchBaseTs, teamSteamIds, applyLeaderChange]);
// Effect NUR an stabile Keys + Tab hängen
useEffect(() => {
@ -785,24 +864,24 @@ export default function MapVotePanel({ match }: Props) {
{isAdmin && isOpen && (
<>
<Button
color={adminEditMode ? 'teal' : 'gray'}
variant={adminEditMode ? 'solid' : 'outline'}
color={editingAsMe ? 'teal' : 'gray'}
variant={editingAsMe ? 'solid' : 'outline'}
size="sm"
className="ml-2"
title={adminEditMode ? 'Admin-Bearbeitung beenden' : 'Map-Vote als Admin bearbeiten'}
title={editingAsMe ? 'Admin-Bearbeitung beenden' : 'Map-Vote als Admin bearbeiten'}
onClick={async () => {
const next = !adminEditMode
setAdminEditMode(next)
const next = !editingAsMe; // am effektiven Zustand ausrichten
setAdminEditMode(next); // sofort kleben
try {
await postAdminEdit(next)
await load()
} catch (e: unknown) {
setAdminEditMode(v => !v)
alert(e instanceof Error ? e.message : 'Fehler beim Umschalten des Admin-Edits')
await postAdminEdit(next); // Server anfordern
scheduleReload(); // State spiegeln
} catch (e) {
setAdminEditMode(v => !v); // Revert bei Fehler
alert(e instanceof Error ? e.message : 'Fehler beim Umschalten des Admin-Edits');
}
}}
>
{adminEditMode ? 'Bearbeiten: AN' : 'Bearbeiten'}
{editingAsMe ? 'Bearbeiten: AN' : 'Bearbeiten'}
</Button>
<Button
@ -820,7 +899,7 @@ export default function MapVotePanel({ match }: Props) {
alert(j.message ?? 'Reset fehlgeschlagen')
return
}
await load()
scheduleReload();
} catch {
alert('Netzwerkfehler beim Reset')
}
@ -1041,8 +1120,8 @@ export default function MapVotePanel({ match }: Props) {
src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
width={40}
height={40}
width={10}
height={10}
/>
) : <div className="w-10 h-10" />}
@ -1104,6 +1183,8 @@ export default function MapVotePanel({ match }: Props) {
src={getTeamLogo(teamRight?.logo)}
alt={teamRight?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
width={10}
height={10}
/>
) : <div className="w-10 h-10" />}
</li>
@ -1265,8 +1346,7 @@ export default function MapVotePanel({ match }: Props) {
src={bg}
alt={label}
className="absolute inset-0 w-full h-full object-cover"
width={40}
height={40}
fill
/>
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
@ -1278,8 +1358,8 @@ export default function MapVotePanel({ match }: Props) {
alt="Picker-Team"
className={`absolute ${cornerPos} w-6 h-6 rounded-full object-contain bg-white/90 border border-white/70 shadow-sm`}
style={{ zIndex: 25 }}
width={40}
height={40}
width={6}
height={6}
/>
)}
@ -1309,8 +1389,7 @@ export default function MapVotePanel({ match }: Props) {
src={mapLogo}
alt={label}
className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg"
width={40}
height={40}
fill
/>
<span className="px-2 py-0.5 rounded-md text-white/90 font-semibold text-xs md:text-sm">
{label}

View File

@ -112,6 +112,8 @@ export default function TeamCardComponent({
const [showInviteModal, setShowInviteModal] = useState(false)
const lastInviteCheck = useRef<number>(0)
const fullLoadedFor = useRef<Set<string>>(new Set());
// 🔒 Flood-Guards
const lastHandledRef = useRef<string>('') // Event-Dedupe
@ -121,32 +123,41 @@ export default function TeamCardComponent({
/* ------- User+Teams laden (einmalig, aber stabil typisiert) ------- */
const loadUserTeams = useCallback(async () => {
try {
setInitialLoading(true)
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) throw new Error('failed /api/user')
const data: unknown = await res.json()
setInitialLoading(true);
const res = await fetch('/api/user', { cache: 'no-store' });
if (!res.ok) throw new Error('failed /api/user');
const data: unknown = await res.json();
const teams: Team[] = Array.isArray((data as { teams?: unknown }).teams)
? ((data as { teams: Team[] }).teams)
: []
: [];
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
setMyTeams(prev =>
(prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])))
? prev
: teams
);
// Auto-Auswahl
if (teams.length === 1) {
setSelectedTeam(teams[0])
} else if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) {
setSelectedTeam(null)
}
// ⚠️ WICHTIG: selectedTeam nicht überschreiben, sondern behutsam setzen/mergen
setSelectedTeam(prev => {
if (teams.length === 1) {
const t0 = teams[0];
if (prev && prev.id === t0.id) {
// „volles“ Objekt behalten und nur harmlose Felder mergen
return { ...prev, name: t0.name, logo: t0.logo, leader: t0.leader };
}
return t0; // erstmalige Auswahl
}
// wenn das gewählte Team nicht mehr existiert → deselecten
if (prev && !teams.some(t => t.id === prev.id)) return null;
return prev;
});
// Einladungen leeren, wenn ich mind. ein Team habe
if (teams.length > 0 && pendingInvitations.length) {
setPendingInvitations([])
}
if (teams.length > 0 && pendingInvitations.length) setPendingInvitations([]);
} finally {
setInitialLoading(false)
setInitialLoading(false);
}
}, [pendingInvitations.length, selectedTeam])
}, [pendingInvitations.length]);
useEffect(() => {
// einmalig ausführen; loadUserTeams ist memoized (siehe deps)
@ -155,58 +166,69 @@ export default function TeamCardComponent({
/* ------- Gedrosseltes Soft-Reload ------- */
const softReload = useCallback(async () => {
const now = Date.now()
if (softReloadInFlight.current) return
if (now - lastSoftReloadAt.current < 500) return // 500ms Cooldown
const now = Date.now();
if (softReloadInFlight.current) return;
if (now - lastSoftReloadAt.current < 800) return; // kleiner Cooldown
softReloadInFlight.current = true
lastSoftReloadAt.current = now
softReloadInFlight.current = true;
lastSoftReloadAt.current = now;
try {
const res = await fetch('/api/user', { cache: 'no-store' })
if (!res.ok) return
const data: unknown = await res.json()
const res = await fetch('/api/user', { cache: 'no-store' });
if (!res.ok) return;
const data: unknown = await res.json();
const teams: Team[] = Array.isArray((data as { teams?: unknown }).teams)
? ((data as { teams: Team[] }).teams)
: []
? (data as { teams: Team[] }).teams
: [];
setMyTeams(prev => (prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])) ? prev : teams))
// myTeams nur aktualisieren, wenn sich wirklich etwas geändert hat
setMyTeams(prev =>
(prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])))
? prev
: teams
);
if (teams.length === 1) setSelectedTeam(teams[0])
else if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) setSelectedTeam(null)
// selectedTeam NICHT „downgraden“ (volles Objekt behalten)
setSelectedTeam(prev => {
if (teams.length === 1) {
const t0 = teams[0];
if (!prev) return t0; // erstmalige Auswahl
if (prev.id !== t0.id) return prev; // andere Auswahl bleibt bestehen
// nur harmlose Felder mergen (Name/Logo/Leader), volle Felder (invitedPlayers etc.) behalten
return { ...prev, name: t0.name, logo: t0.logo, leader: t0.leader };
}
// falls das gewählte Team nicht mehr vorhanden ist → deselecten
if (prev && !teams.some(t => t.id === prev.id)) return null;
return prev;
});
if (teams.length > 0 && pendingInvitations.length) setPendingInvitations([])
// Einladungen räumen, wenn ich jetzt in einem Team bin
if (teams.length > 0 && pendingInvitations.length) {
setPendingInvitations([]);
}
// Einladungen nachladen falls kein Team
// Wenn in keinem Team: Einladungen (gedrosselt) nachladen
if (teams.length === 0 && Date.now() - lastInviteCheck.current > 1500) {
lastInviteCheck.current = Date.now()
const inv = await fetch('/api/user/invitations', { cache: 'no-store' })
lastInviteCheck.current = Date.now();
const inv = await fetch('/api/user/invitations', { cache: 'no-store' });
if (inv.ok) {
const json: unknown = await inv.json()
type RawInv = { id?: string; type?: string; team?: Team }
const json: unknown = await inv.json();
type RawInv = { id?: string; type?: string; team?: Team };
const rawList: RawInv[] = Array.isArray((json as { invitations?: unknown }).invitations)
? ((json as { invitations: RawInv[] }).invitations)
: []
? (json as { invitations: RawInv[] }).invitations
: [];
const all: Invitation[] = rawList
.filter((i): i is Required<Pick<RawInv, 'id' | 'team'>> & RawInv => !!i.id && i.type === 'team-invite' && !!i.team)
.map((i) => ({ id: i.id!, type: 'team-invite', team: i.team! }))
.map(i => ({ id: i.id!, type: 'team-invite', team: i.team! }));
setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all))
}
}
// selektiertes Team vollständig aktualisieren
if (selectedTeam) {
const full = await loadTeamFull(selectedTeam.id)
if (full) {
setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t))
setSelectedTeam(full)
setPendingInvitations(prev => (eqInviteList(prev, all) ? prev : all));
}
}
} finally {
softReloadInFlight.current = false
softReloadInFlight.current = false;
}
}, [pendingInvitations.length, selectedTeam])
}, [pendingInvitations.length, selectedTeam?.id]);
/* ------- SSE-gestützte Updates (dedupliziert) ------- */
useEffect(() => {
@ -268,19 +290,23 @@ export default function TeamCardComponent({
// wenn selectedTeam nur aus der /api/user-Quelle kommt (ohne invitedPlayers), einmalig vollständig laden
useEffect(() => {
if (!selectedTeam) return
if (Array.isArray(selectedTeam.invitedPlayers)) return
if (!selectedTeam) return;
if (Array.isArray(selectedTeam.invitedPlayers)) return;
let cancelled = false
;(async () => {
const full = await loadTeamFull(selectedTeam.id)
if (!full || cancelled) return
setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t))
setSelectedTeam(full)
})()
// schon für diese ID vollgeladen?
if (fullLoadedFor.current.has(selectedTeam.id)) return;
fullLoadedFor.current.add(selectedTeam.id);
return () => { cancelled = true }
}, [selectedTeam])
let cancelled = false;
(async () => {
const full = await loadTeamFull(selectedTeam.id);
if (!full || cancelled) return;
setMyTeams(prev => prev.map(t => t.id === full.id ? full : t));
setSelectedTeam(prev => (prev && prev.id === full.id) ? { ...prev, ...full } : full);
})();
return () => { cancelled = true; };
}, [selectedTeam?.id]);
/* ------- Render-Zweige ------- */

View File

@ -1,4 +1,4 @@
// /src/app/[locale]/team/[teamId]/TeamClient.tsx
// /src/app/[locale]/team/[teamId]/TeamDetailClient.tsx
'use client'
import { useEffect, useMemo, useState, KeyboardEvent, MouseEvent, ChangeEvent } from 'react'
@ -43,29 +43,24 @@ export default function TeamDetailClient({ teamId }: { teamId: string }) {
const [seg, setSeg] = useState<'active' | 'inactive' | 'invited'>('active')
useEffect(() => {
let alive = true
;(async () => {
const ac = new AbortController();
(async () => {
try {
setLoading(true)
setError(null)
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
if (res.status === 404) {
router.replace('/404')
return
}
if (!res.ok) throw new Error('Team konnte nicht geladen werden')
const data: Team = await res.json()
if (alive) setTeam(data)
} catch (e: unknown) {
if (alive) setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setLoading(true);
const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store', signal: ac.signal });
if (res.status === 404) { router.replace('/404'); return; }
if (!res.ok) throw new Error('Team konnte nicht geladen werden');
const data: Team = await res.json();
setTeam(data);
} catch (e) {
if (!ac.signal.aborted) setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
} finally {
if (alive) setLoading(false)
if (!ac.signal.aborted) setLoading(false);
}
})()
return () => {
alive = false
}
}, [teamId, router])
})();
return () => ac.abort();
}, [teamId]); // ← router entfernt
/* ---------- Ableitungen ---------- */

View File

@ -1,6 +1,6 @@
// /src/app/[locale]/team/[teamId]/page.tsx
import type { AppPageProps } from '@/types/next'
import TeamDetailClient from './TeamClient'
import TeamDetailClient from './TeamDetailClient'
export default async function Page({ params }: AppPageProps<{ teamId: string }>) {
const { teamId } = await params