ironie-nextjs/src/app/[locale]/components/InvitePlayersModal.tsx
2025-10-14 15:30:11 +02:00

590 lines
21 KiB
TypeScript

// /src/app/[locale]/components/InvitePlayersModal.tsx
'use client'
import { useState, useEffect, useRef, useCallback, useMemo, useTransition } from 'react'
import Modal from './Modal'
import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react'
import LoadingSpinner from './LoadingSpinner'
import { Player, Team } from '../../../types/team'
import Pagination from './Pagination'
import { AnimatePresence, motion } from 'framer-motion'
import Switch from './Switch'
type InviteStatus = 'sent' | 'failed' | 'added' | 'pending'
type Props = {
show: boolean
onClose: () => void
onSuccess: () => void
team: Team
directAdd?: boolean
}
type UnknownRec = Record<string, unknown>;
type ApiResultItem = { steamId: string; ok: boolean };
function parseResultsFromJson(json: unknown): ApiResultItem[] | null {
if (!isRecord(json)) return null;
if (Array.isArray(json.results)) {
const out: ApiResultItem[] = [];
for (const r of json.results) {
const steamIdVal = isRecord(r) ? r.steamId : undefined;
const okVal = isRecord(r) ? r.ok : undefined;
const steamId =
typeof steamIdVal === 'string' ? steamIdVal
: typeof steamIdVal === 'number' ? String(steamIdVal)
: null;
const ok = typeof okVal === 'boolean' ? okVal : false;
if (steamId) out.push({ steamId, ok });
}
return out;
}
if (Array.isArray(json.invitationIds)) {
const ids: string[] = (json.invitationIds as unknown[]).map(v =>
typeof v === 'string' ? v : String(v)
);
return ids.map(steamId => ({ steamId, ok: true }));
}
return null;
}
function isRecord(v: unknown): v is UnknownRec {
return !!v && typeof v === 'object';
}
function getTeamLeaderSteamId(team: Team | null | undefined): string | null {
if (!team) return null;
const leaderUnknown = (team as unknown as { leader?: unknown }).leader;
if (typeof leaderUnknown === 'string') return leaderUnknown;
if (isRecord(leaderUnknown) && typeof leaderUnknown.steamId === 'string') {
return leaderUnknown.steamId;
}
return null;
}
export default function InvitePlayersModal({ show, onClose, onSuccess, team, directAdd = false }: Props) {
const { data: session } = useSession()
const steamId = session?.user?.steamId
const [allUsers, setAllUsers] = useState<Player[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [knownUsers, setKnownUsers] = useState<Record<string, Player>>({})
const [invitedIds, setInvitedIds] = useState<string[]>([])
const [isInviting, setIsInviting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [sentCount, setSentCount] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
// Toggle: nur Spieler ohne Team
const [onlyFree, setOnlyFree] = useState(false)
// Sanftes UI-Update
const [, startTransition] = useTransition()
const [isFetching, setIsFetching] = useState(false)
const abortRef = useRef<AbortController | null>(null)
// 🔽 Anti-Flacker: verzögert anzeigen + Mindestdauer
const SPINNER_DELAY_MS = 120
const SPINNER_MIN_MS = 250
const [spinnerVisible, setSpinnerVisible] = useState(false)
const spinnerShowTimer = useRef<number | null>(null)
const spinnerShownAt = useRef<number | null>(null)
// Dynamisch berechnete Items pro Seite
const [usersPerPage, setUsersPerPage] = useState<number>(9)
const [currentPage, setCurrentPage] = useState(1)
const [invitedStatus, setInvitedStatus] = useState<Record<string, InviteStatus>>({})
// Refs / Layout
const descRef = useRef<HTMLParagraphElement>(null)
const selectedWrapRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLDivElement>(null)
const successRef = useRef<HTMLDivElement>(null)
const gridRef = useRef<HTMLDivElement>(null)
const firstCardRef = useRef<HTMLDivElement>(null)
const showControls = !isSuccess
// aktuelle Grid-Höhe halten
const [gridHoldHeight, setGridHoldHeight] = useState<number>(0)
useEffect(() => {
if (!show) return
void fetchUsers({ resetLayout: true })
setIsSuccess(false)
setInvitedIds([])
setInvitedStatus({})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show])
useEffect(() => {
if (!show) return
setCurrentPage(1)
void fetchUsers({ resetLayout: false })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onlyFree])
useEffect(() => {
if (!gridRef.current) return
const observer = new ResizeObserver(() => {
if (!gridRef.current) return
const gridHeight = gridRef.current.clientHeight
const cardHeight = gridRef.current.querySelector('div')?.clientHeight || 0
const rows = cardHeight > 0 ? Math.floor(gridHeight / cardHeight) : 1
const cols = window.innerWidth >= 640 ? 3 : 2
setUsersPerPage(rows * cols)
})
observer.observe(gridRef.current)
return () => observer.disconnect()
}, [])
async function fetchUsers(opts: { resetLayout: boolean }) {
try {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
if (gridRef.current) setGridHoldHeight(gridRef.current.clientHeight);
setIsFetching(true);
if (spinnerShowTimer.current) window.clearTimeout(spinnerShowTimer.current);
spinnerShowTimer.current = window.setTimeout(() => {
setSpinnerVisible(true);
spinnerShownAt.current = Date.now();
}, SPINNER_DELAY_MS);
const qs = new URLSearchParams({ teamId: team.id });
if (onlyFree) qs.set('onlyFree', 'true');
const res = await fetch(`/api/team/available-users?${qs.toString()}`, {
signal: ctrl.signal,
cache: 'no-store',
});
if (!res.ok) throw new Error('load failed');
const dataUnknown: unknown = await res.json();
const users = (isRecord(dataUnknown) && Array.isArray(dataUnknown.users))
? (dataUnknown.users as Player[])
: [];
startTransition(() => {
setAllUsers(users);
setKnownUsers(prev => {
const next = { ...prev };
for (const u of users) next[u.steamId] = u;
return next;
});
if (opts.resetLayout) {
setSelectedIds([]);
setInvitedIds([]);
setIsSuccess(false);
}
});
} catch (e: unknown) {
if (isRecord(e) && e.name === 'AbortError') return;
console.error('Fehler beim Laden der Benutzer:', e);
} finally {
setIsFetching(false)
abortRef.current = null
// 🔽 Spinner Mindestdauer respektieren
const hide = () => {
setSpinnerVisible(false)
spinnerShownAt.current = null
setGridHoldHeight(0)
}
if (spinnerShowTimer.current) {
// Wenn der Delay-Timer noch nicht gefeuert hat: einfach abbrechen
window.clearTimeout(spinnerShowTimer.current)
spinnerShowTimer.current = null
// Der Spinner wurde evtl. nie sichtbar -> direkt aufräumen
if (!spinnerShownAt.current) {
hide()
return
}
}
if (spinnerShownAt.current) {
const elapsed = Date.now() - spinnerShownAt.current
const remain = Math.max(0, SPINNER_MIN_MS - elapsed)
if (remain > 0) {
window.setTimeout(hide, remain)
} else {
hide()
}
} else {
// Falls er nie sichtbar war
hide()
}
}
}
const handleSelect = (steamId: string) => {
setSelectedIds(prev => {
const isSelected = prev.includes(steamId)
// Beim Hinzufügen: sicherstellen, dass wir die Card-Daten im Cache haben
if (!isSelected) {
const u = allUsers.find(u => u.steamId === steamId)
if (u) {
setKnownUsers(map => (map[steamId] ? map : { ...map, [steamId]: u }))
}
}
return isSelected ? prev.filter(id => id !== steamId) : [...prev, steamId]
})
}
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 }
: { teamId: team.id, userIds: ids, invitedBy: steamId };
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
let jsonUnknown: unknown = null;
try { jsonUnknown = await res.clone().json(); } catch { /* ignore */ }
let results: ApiResultItem[] | null = parseResultsFromJson(jsonUnknown);
// Fallback, falls API keine detailierten results liefert
if (!results) {
results = ids.map(id => ({ steamId: id, ok: res.ok }));
}
const nextStatus: Record<string, InviteStatus> = {};
let okCount = 0;
for (const r of results) {
const st: InviteStatus = r.ok ? (directAdd ? 'added' : 'sent') : 'failed';
nextStatus[r.steamId] = st;
if (r.ok) okCount++;
}
setInvitedStatus(prev => ({ ...prev, ...nextStatus }));
setInvitedIds(ids);
setSentCount(okCount);
setIsSuccess(true);
setSelectedIds([]);
if (okCount > 0) onSuccess();
} catch (err: unknown) {
console.error('Fehler beim Einladen:', err);
setInvitedStatus(prev => ({
...prev,
...Object.fromEntries(selectedIds.map(id => [id, 'failed' as InviteStatus])),
}));
setInvitedIds(selectedIds);
setSentCount(0);
setIsSuccess(true);
} finally {
setIsInviting(false);
}
};
useEffect(() => { setCurrentPage(1) }, [searchTerm])
const filteredUsers = useMemo(() => {
if (isSuccess) return allUsers
return allUsers.filter(u => u.name?.toLowerCase().includes(searchTerm.toLowerCase()))
}, [allUsers, searchTerm, isSuccess])
const unselectedUsers = useMemo(() => {
if (isSuccess) return filteredUsers
return filteredUsers.filter(user =>
!selectedIds.includes(user.steamId) &&
(!isSuccess || !invitedIds.includes(user.steamId))
)
}, [filteredUsers, selectedIds, invitedIds, isSuccess])
const totalPages = Math.ceil(unselectedUsers.length / Math.max(1, usersPerPage))
const startIdx = (currentPage - 1) * Math.max(1, usersPerPage)
const paginatedUsers = unselectedUsers.slice(startIdx, startIdx + Math.max(1, usersPerPage))
useEffect(() => {
if (totalPages === 0 && currentPage !== 1) setCurrentPage(1)
else if (currentPage > totalPages) setCurrentPage(totalPages || 1)
}, [totalPages, currentPage])
const recalcUsersPerPage = useCallback(() => {
const gridEl = gridRef.current
if (!gridEl) return
const bodyEl = gridEl.parentElement as HTMLElement | null
if (!bodyEl) return
const outer = (el: HTMLElement | null) => {
if (!el) return 0
const cs = getComputedStyle(el)
return el.offsetHeight + parseFloat(cs.marginTop || '0') + parseFloat(cs.marginBottom || '0')
}
const gridMT = parseFloat(getComputedStyle(gridEl).marginTop || '0')
const usedAbove =
outer(descRef.current) +
outer(selectedWrapRef.current) +
outer(searchRef.current) +
outer(successRef.current) +
gridMT
const reserveForPagination = (!isFetching && !isSuccess && totalPages > 1) ? 48 : 0
const availableHeight = Math.max(0, bodyEl.clientHeight - usedAbove - reserveForPagination)
const csGrid = getComputedStyle(gridEl)
const cols = Math.max(1, csGrid.gridTemplateColumns.split(' ').filter(Boolean).length)
const rowGap = parseFloat(csGrid.rowGap || '0')
const cardEl = firstCardRef.current
let cardHeight = cardEl?.offsetHeight || 0
if (!cardHeight) cardHeight = 140
const rows = Math.max(1, Math.floor((availableHeight + rowGap) / (cardHeight + rowGap)))
const nextPerPage = Math.max(1, rows * cols)
setUsersPerPage(prev => (prev !== nextPerPage ? nextPerPage : prev))
}, [isFetching, isSuccess, totalPages])
useEffect(() => {
recalcUsersPerPage()
const gridEl = gridRef.current
if (!gridEl) return
const bodyEl = gridEl.parentElement as HTMLElement | null
const cardEl = firstCardRef.current
const ro = new ResizeObserver(() => recalcUsersPerPage())
ro.observe(gridEl)
if (bodyEl) ro.observe(bodyEl)
if (cardEl) ro.observe(cardEl)
const onResize = () => recalcUsersPerPage()
window.addEventListener('resize', onResize)
const id = window.setTimeout(recalcUsersPerPage, 60)
return () => {
window.clearTimeout(id)
ro.disconnect()
window.removeEventListener('resize', onResize)
}
}, [recalcUsersPerPage, show, filteredUsers.length, isSuccess])
return (
<Modal
id="invite-members-modal"
title={directAdd ? 'Spieler hinzufügen' : 'Spieler einladen'}
show={show}
onClose={onClose}
onSave={!isSuccess ? (() => { if (!isInviting) handleInvite() }) : undefined}
closeButtonColor={!isSuccess ? (isSuccess ? 'teal' : 'blue') : undefined}
closeButtonTitle={
!isSuccess
? (isInviting ? (directAdd ? 'Wird hinzugefügt...' : 'Wird eingeladen...') : (directAdd ? 'Hinzufügen' : 'Einladungen senden'))
: undefined
}
closeButtonLoading={!isSuccess && isInviting}
scrollBody
>
{showControls && (
<p ref={descRef} className="text-sm text-gray-700 dark:text-neutral-300 mb-2">
{directAdd
? 'Wähle Spieler aus, die du direkt zum Team hinzufügen möchtest:'
: 'Wähle Spieler aus, die du in dein Team einladen möchtest:'}
</p>
)}
{/* Filterleiste */}
{showControls && (
<div ref={searchRef} className="mt-1 grid grid-cols-[auto_1fr] items-center gap-x-3">
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-40 sm:w-56 rounded border px-2 py-1.5 text-[13px]
focus:outline-none focus:ring focus:ring-blue-400
dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-100"
/>
<div className="justify-self-end min-w-[240px] sm:min-w-[300px]">
<Switch
id="only-free-switch"
checked={onlyFree}
onChange={(v) => startTransition(() => setOnlyFree(v))}
labelLeft="Alle"
labelRight="Nur ohne Team"
/>
</div>
</div>
)}
{/* Ausgewählte */}
{selectedIds.length > 0 && (
<div ref={selectedWrapRef} className="col-span-full mt-2">
<h3 className="text-sm font-semibold text-gray-700 dark:text-neutral-300 mb-2">Ausgewählte Spieler:</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-2">
<AnimatePresence initial={false}>
{selectedIds.map((id) => {
const user = knownUsers[id]
if (!user) return null
return (
<motion.div key={user.steamId} layout initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<MiniCard
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected
onSelect={handleSelect}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions
rank={user.premierRank}
/>
</motion.div>
)
})}
</AnimatePresence>
</div>
</div>
)}
{isSuccess && (
<div
ref={successRef}
className={`mt-2 px-4 py-2 text-sm rounded-lg border transition-opacity duration-150 ${
isSuccess ? 'opacity-100' : 'opacity-0'
}`}
style={{
minHeight: 40, // ~ Höhe reservieren, vermeidet Jump
background: sentCount ? '#dcfce7' : '#fee2e2',
borderColor: sentCount ? '#bbf7d0' : '#fecaca',
color: sentCount ? '#166534' : '#991b1b'
}}
>
{isSuccess && (
<>
{directAdd
? (sentCount === 0
? 'Niemand konnte hinzugefügt werden.'
: sentCount === invitedIds.length
? `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt!`
: `${sentCount} Mitglied${sentCount === 1 ? '' : 'er'} hinzugefügt, andere fehlgeschlagen.`)
: (sentCount === 0
? 'Keine Einladungen versendet.'
: sentCount === invitedIds.length
? `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet!`
: `${sentCount} Einladung${sentCount === 1 ? '' : 'en'} versendet, andere fehlgeschlagen.`)}
</>
)}
</div>
)}
{/* Grid */}
<div
ref={gridRef}
className="relative grid grid-cols-2 sm:grid-cols-3 gap-4 mt-4 transition-[min-height] duration-150"
style={gridHoldHeight ? { minHeight: gridHoldHeight } : undefined}
>
{unselectedUsers.length === 0 ? (
<div className="col-span-full text-center text-gray-500 dark:text-neutral-400">
{allUsers.length === 0
? directAdd ? 'Keine Benutzer verfügbar :(' : 'Niemand zum Einladen verfügbar :('
: 'Keine Benutzer gefunden.'}
</div>
) : (
<AnimatePresence mode="popLayout" initial={false}>
{!isSuccess && paginatedUsers.map((user, idx) => (
<motion.div
key={user.steamId}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
ref={idx === 0 ? firstCardRef : undefined}
>
<MiniCard
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected={false}
draggable={false}
onSelect={handleSelect}
currentUserSteamId={steamId!}
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions
rank={user.premierRank}
invitedStatus={invitedStatus[user.steamId]}
/>
</motion.div>
))}
{isSuccess && invitedIds.map((id, idx) => {
const user = knownUsers[id]
if (!user) return null
return (
<motion.div
key={`invited-${user.steamId}`}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
ref={idx === 0 ? firstCardRef : undefined}
>
<MiniCard
steamId={user.steamId}
title={user.name}
avatar={user.avatar}
location={user.location}
selected={false}
draggable={false}
currentUserSteamId={steamId!}
teamLeaderSteamId={getTeamLeaderSteamId(team) ?? undefined}
hideActions
rank={user.premierRank}
invitedStatus={invitedStatus[user.steamId]}
/>
</motion.div>
)
})}
</AnimatePresence>
)}
{/* Overlay-Spinner: erscheint verzögert und bleibt min. SPINNER_MIN_MS sichtbar */}
{spinnerVisible && (
<div className="absolute inset-0 bg-black/5 dark:bg-white/5 backdrop-blur-[1px] pointer-events-none flex items-center justify-center rounded transition-opacity duration-150">
<LoadingSpinner />
</div>
)}
</div>
{/* Pagination */}
<div className="mt-3 min-h-[44px] flex justify-center items-center">
{!isFetching && !isSuccess && totalPages > 1 ? (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={(p) => {
const next = Math.max(1, Math.min(totalPages, p))
setCurrentPage(next)
setTimeout(() => recalcUsersPerPage(), 0)
}}
/>
) : null}
</div>
</Modal>
)
}