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 { lastEvent } = useSSEStore()
const { open: overlayOpen, data: overlayData, showWithDelay } = useReadyOverlayStore() 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 -------- */ /* -------- Local state -------- */
const [state, setState] = useState<MapVoteState | null>(null) const [state, setState] = useState<MapVoteState | null>(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@ -214,47 +220,108 @@ export default function MapVotePanel({ match }: Props) {
}, [lastEvent, match.id, showWithDelay]); }, [lastEvent, match.id, showWithDelay]);
/* -------- Data load (initial + SSE refresh) -------- */ /* -------- Data load (initial + SSE refresh) -------- */
const load = useCallback(async () => { const doLoad = useCallback(async () => {
setIsLoading(true) // alte Anfrage abbrechen
setError(null) abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setIsLoading(true);
setError(null);
try { 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) { if (!r.ok) {
const j = await r.json().catch(() => ({} as { message?: string })) // versuch eine brauchbare Fehlermeldung
throw new Error(j?.message || 'Laden fehlgeschlagen') let msg = 'Laden fehlgeschlagen';
try { msg = (await r.json())?.message ?? msg; } catch {}
throw new Error(msg);
} }
const json = await r.json() 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) setState(json);
} catch (e: unknown) { } catch (e) {
setState(null) if ((e as any)?.name === 'AbortError') return; // wurde abgebrochen -> still
setError(e instanceof Error ? e.message : 'Unbekannter Fehler') setState(null);
setError(e instanceof Error ? e.message : 'Unbekannter Fehler');
} finally { } 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 adminEditingBy = state?.adminEdit?.by ?? null
const adminEditingEnabled = !!state?.adminEdit?.enabled const adminEditingEnabled = !!state?.adminEdit?.enabled
useEffect(() => { useEffect(() => {
const iAmEditing = adminEditingEnabled && adminEditingBy === session?.user?.steamId const serverEnabled = !!state?.adminEdit?.enabled;
setAdminEditMode(iAmEditing) const serverBy = state?.adminEdit?.by ?? null;
}, [adminEditingEnabled, adminEditingBy, session?.user?.steamId]) 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 -------- */ /* -------- Derived flags & memoized maps -------- */
const me = session?.user const me = session?.user
const isAdmin = !!me?.isAdmin const isAdmin = !!me?.isAdmin
const mySteamId = me?.steamId 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 leaderAId = state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId ?? null
const leaderBId = state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId ?? null const leaderBId = state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId ?? null
const isLeaderA = !!mySteamId && leaderAId === mySteamId const isLeaderA = !!mySteamId && leaderAId === mySteamId
const isLeaderB = !!mySteamId && leaderBId === mySteamId const isLeaderB = !!mySteamId && leaderBId === mySteamId
const isFrozenByAdmin = adminEditingEnabled && adminEditingBy !== mySteamId
const currentStep = state?.steps?.[state?.currentIndex ?? 0] const currentStep = state?.steps?.[state?.currentIndex ?? 0]
const canActForTeamId = useCallback( const canActForTeamId = useCallback(
(teamId?: string | null) => (teamId?: string | null) =>
@ -268,7 +335,7 @@ export default function MapVotePanel({ match }: Props) {
!state?.locked && !state?.locked &&
!isFrozenByAdmin && !isFrozenByAdmin &&
currentStep?.teamId && currentStep?.teamId &&
(canActForTeamId(currentStep.teamId) || (isAdmin && adminEditMode)) (canActForTeamId(currentStep.teamId) || (isAdmin && editingAsMe))
) )
const decisionByMap = useMemo(() => { const decisionByMap = useMemo(() => {
@ -340,6 +407,7 @@ export default function MapVotePanel({ match }: Props) {
const holdMapRef = useRef<string | null>(null) const holdMapRef = useRef<string | null>(null)
const submittedRef = useRef<boolean>(false) const submittedRef = useRef<boolean>(false)
const [progressByMap, setProgressByMap] = useState<Record<string, number>>({}) const [progressByMap, setProgressByMap] = useState<Record<string, number>>({})
const lastEvtKeyRef = useRef<string>(''); // Dedupe gleicher Events
const resetHold = useCallback(() => { const resetHold = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current) if (rafRef.current) cancelAnimationFrame(rafRef.current)
@ -711,9 +779,20 @@ export default function MapVotePanel({ match }: Props) {
const shouldRefresh = isRefreshEvent(type); const shouldRefresh = isRefreshEvent(type);
if (shouldRefresh) { 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 // Effect NUR an stabile Keys + Tab hängen
useEffect(() => { useEffect(() => {
@ -785,24 +864,24 @@ export default function MapVotePanel({ match }: Props) {
{isAdmin && isOpen && ( {isAdmin && isOpen && (
<> <>
<Button <Button
color={adminEditMode ? 'teal' : 'gray'} color={editingAsMe ? 'teal' : 'gray'}
variant={adminEditMode ? 'solid' : 'outline'} variant={editingAsMe ? 'solid' : 'outline'}
size="sm" size="sm"
className="ml-2" 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 () => { onClick={async () => {
const next = !adminEditMode const next = !editingAsMe; // am effektiven Zustand ausrichten
setAdminEditMode(next) setAdminEditMode(next); // sofort kleben
try { try {
await postAdminEdit(next) await postAdminEdit(next); // Server anfordern
await load() scheduleReload(); // State spiegeln
} catch (e: unknown) { } catch (e) {
setAdminEditMode(v => !v) setAdminEditMode(v => !v); // Revert bei Fehler
alert(e instanceof Error ? e.message : 'Fehler beim Umschalten des Admin-Edits') alert(e instanceof Error ? e.message : 'Fehler beim Umschalten des Admin-Edits');
} }
}} }}
> >
{adminEditMode ? 'Bearbeiten: AN' : 'Bearbeiten'} {editingAsMe ? 'Bearbeiten: AN' : 'Bearbeiten'}
</Button> </Button>
<Button <Button
@ -820,7 +899,7 @@ export default function MapVotePanel({ match }: Props) {
alert(j.message ?? 'Reset fehlgeschlagen') alert(j.message ?? 'Reset fehlgeschlagen')
return return
} }
await load() scheduleReload();
} catch { } catch {
alert('Netzwerkfehler beim Reset') alert('Netzwerkfehler beim Reset')
} }
@ -1041,8 +1120,8 @@ export default function MapVotePanel({ match }: Props) {
src={getTeamLogo(teamLeft?.logo)} src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'} alt={teamLeft?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
width={40} width={10}
height={40} height={10}
/> />
) : <div className="w-10 h-10" />} ) : <div className="w-10 h-10" />}
@ -1104,6 +1183,8 @@ export default function MapVotePanel({ match }: Props) {
src={getTeamLogo(teamRight?.logo)} src={getTeamLogo(teamRight?.logo)}
alt={teamRight?.name ?? 'Team'} alt={teamRight?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain" 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" />} ) : <div className="w-10 h-10" />}
</li> </li>
@ -1265,8 +1346,7 @@ export default function MapVotePanel({ match }: Props) {
src={bg} src={bg}
alt={label} alt={label}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
width={40} fill
height={40}
/> />
)} )}
<div className="absolute inset-0 bg-gradient-to-b from-black/80 via-black/65 to-black/80" /> <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" alt="Picker-Team"
className={`absolute ${cornerPos} w-6 h-6 rounded-full object-contain bg-white/90 border border-white/70 shadow-sm`} className={`absolute ${cornerPos} w-6 h-6 rounded-full object-contain bg-white/90 border border-white/70 shadow-sm`}
style={{ zIndex: 25 }} style={{ zIndex: 25 }}
width={40} width={6}
height={40} height={6}
/> />
)} )}
@ -1309,8 +1389,7 @@ export default function MapVotePanel({ match }: Props) {
src={mapLogo} src={mapLogo}
alt={label} alt={label}
className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg" className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg"
width={40} fill
height={40}
/> />
<span className="px-2 py-0.5 rounded-md text-white/90 font-semibold text-xs md:text-sm"> <span className="px-2 py-0.5 rounded-md text-white/90 font-semibold text-xs md:text-sm">
{label} {label}

View File

@ -113,6 +113,8 @@ export default function TeamCardComponent({
const lastInviteCheck = useRef<number>(0) const lastInviteCheck = useRef<number>(0)
const fullLoadedFor = useRef<Set<string>>(new Set());
// 🔒 Flood-Guards // 🔒 Flood-Guards
const lastHandledRef = useRef<string>('') // Event-Dedupe const lastHandledRef = useRef<string>('') // Event-Dedupe
const softReloadInFlight = useRef(false) // keine Parallel-Reloads const softReloadInFlight = useRef(false) // keine Parallel-Reloads
@ -121,32 +123,41 @@ export default function TeamCardComponent({
/* ------- User+Teams laden (einmalig, aber stabil typisiert) ------- */ /* ------- User+Teams laden (einmalig, aber stabil typisiert) ------- */
const loadUserTeams = useCallback(async () => { const loadUserTeams = useCallback(async () => {
try { try {
setInitialLoading(true) setInitialLoading(true);
const res = await fetch('/api/user', { cache: 'no-store' }) const res = await fetch('/api/user', { cache: 'no-store' });
if (!res.ok) throw new Error('failed /api/user') if (!res.ok) throw new Error('failed /api/user');
const data: unknown = await res.json() const data: unknown = await res.json();
const teams: Team[] = Array.isArray((data as { teams?: unknown }).teams) 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)) setMyTeams(prev =>
(prev.length === teams.length && prev.every((t, i) => eqTeam(t, teams[i])))
? prev
: teams
);
// Auto-Auswahl // ⚠️ WICHTIG: selectedTeam nicht überschreiben, sondern behutsam setzen/mergen
if (teams.length === 1) { setSelectedTeam(prev => {
setSelectedTeam(teams[0]) if (teams.length === 1) {
} else if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) { const t0 = teams[0];
setSelectedTeam(null) 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 { } finally {
setInitialLoading(false) setInitialLoading(false);
} }
}, [pendingInvitations.length, selectedTeam]) }, [pendingInvitations.length]);
useEffect(() => { useEffect(() => {
// einmalig ausführen; loadUserTeams ist memoized (siehe deps) // einmalig ausführen; loadUserTeams ist memoized (siehe deps)
@ -155,58 +166,69 @@ export default function TeamCardComponent({
/* ------- Gedrosseltes Soft-Reload ------- */ /* ------- Gedrosseltes Soft-Reload ------- */
const softReload = useCallback(async () => { const softReload = useCallback(async () => {
const now = Date.now() const now = Date.now();
if (softReloadInFlight.current) return if (softReloadInFlight.current) return;
if (now - lastSoftReloadAt.current < 500) return // 500ms Cooldown if (now - lastSoftReloadAt.current < 800) return; // kleiner Cooldown
softReloadInFlight.current = true softReloadInFlight.current = true;
lastSoftReloadAt.current = now lastSoftReloadAt.current = now;
try { try {
const res = await fetch('/api/user', { cache: 'no-store' }) const res = await fetch('/api/user', { cache: 'no-store' });
if (!res.ok) return if (!res.ok) return;
const data: unknown = await res.json()
const data: unknown = await res.json();
const teams: Team[] = Array.isArray((data as { teams?: unknown }).teams) 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]) // selectedTeam NICHT „downgraden“ (volles Objekt behalten)
else if (selectedTeam && !teams.some(t => t.id === selectedTeam.id)) setSelectedTeam(null) 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) { if (teams.length === 0 && Date.now() - lastInviteCheck.current > 1500) {
lastInviteCheck.current = Date.now() lastInviteCheck.current = Date.now();
const inv = await fetch('/api/user/invitations', { cache: 'no-store' }) const inv = await fetch('/api/user/invitations', { cache: 'no-store' });
if (inv.ok) { if (inv.ok) {
const json: unknown = await inv.json() const json: unknown = await inv.json();
type RawInv = { id?: string; type?: string; team?: Team } type RawInv = { id?: string; type?: string; team?: Team };
const rawList: RawInv[] = Array.isArray((json as { invitations?: unknown }).invitations) const rawList: RawInv[] = Array.isArray((json as { invitations?: unknown }).invitations)
? ((json as { invitations: RawInv[] }).invitations) ? (json as { invitations: RawInv[] }).invitations
: [] : [];
const all: Invitation[] = rawList const all: Invitation[] = rawList
.filter((i): i is Required<Pick<RawInv, 'id' | 'team'>> & RawInv => !!i.id && i.type === 'team-invite' && !!i.team) .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)) 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)
} }
} }
} finally { } finally {
softReloadInFlight.current = false softReloadInFlight.current = false;
} }
}, [pendingInvitations.length, selectedTeam]) }, [pendingInvitations.length, selectedTeam?.id]);
/* ------- SSE-gestützte Updates (dedupliziert) ------- */ /* ------- SSE-gestützte Updates (dedupliziert) ------- */
useEffect(() => { useEffect(() => {
@ -268,19 +290,23 @@ export default function TeamCardComponent({
// wenn selectedTeam nur aus der /api/user-Quelle kommt (ohne invitedPlayers), einmalig vollständig laden // wenn selectedTeam nur aus der /api/user-Quelle kommt (ohne invitedPlayers), einmalig vollständig laden
useEffect(() => { useEffect(() => {
if (!selectedTeam) return if (!selectedTeam) return;
if (Array.isArray(selectedTeam.invitedPlayers)) return if (Array.isArray(selectedTeam.invitedPlayers)) return;
let cancelled = false // schon für diese ID vollgeladen?
;(async () => { if (fullLoadedFor.current.has(selectedTeam.id)) return;
const full = await loadTeamFull(selectedTeam.id) fullLoadedFor.current.add(selectedTeam.id);
if (!full || cancelled) return
setMyTeams(prev => prev.map(t => t.id === selectedTeam.id ? full : t))
setSelectedTeam(full)
})()
return () => { cancelled = true } let cancelled = false;
}, [selectedTeam]) (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 ------- */ /* ------- Render-Zweige ------- */

View File

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

View File

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