590 lines
21 KiB
TypeScript
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>
|
|
)
|
|
}
|