updated
This commit is contained in:
parent
15d369b76f
commit
99ad158526
@ -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}
|
||||||
|
|||||||
@ -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 ------- */
|
||||||
|
|
||||||
|
|||||||
@ -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 ---------- */
|
||||||
|
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user