Files
blitzkit/src/app/tools/ratings/page.tsx
T

682 lines
22 KiB
TypeScript
Raw Normal View History

2023-10-29 13:40:32 -05:00
'use client';
2023-11-02 11:14:13 -05:00
import {
CaretLeftIcon,
CaretRightIcon,
MagnifyingGlassIcon,
} from '@radix-ui/react-icons';
2023-11-07 11:55:27 -06:00
import {
Button,
Dialog,
Flex,
Select,
Text,
TextField,
} from '@radix-ui/themes';
2023-10-31 15:20:37 -05:00
import { produce } from 'immer';
2023-11-07 18:58:19 -06:00
import { debounce, range } from 'lodash';
2023-11-07 10:32:01 -06:00
import { ChangeEvent, useEffect, useRef, useState } from 'react';
2023-10-31 13:15:28 -05:00
import useSWR from 'swr';
2023-10-31 15:20:37 -05:00
import { create } from 'zustand';
2023-11-07 18:58:19 -06:00
import { BlitzkriegRatingsLeaderboardEntry } from '../../../../scripts/buildRatingsLeaderboard';
2023-10-31 13:30:47 -05:00
import * as Leaderboard from '../../../components/Leaderboard';
2023-10-29 13:40:32 -05:00
import PageWrapper from '../../../components/PageWrapper';
2023-11-07 11:02:11 -06:00
import { LEAGUES } from '../../../constants/leagues';
2023-11-01 11:27:41 -05:00
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../../../constants/ratings';
2023-10-30 21:57:22 -05:00
import { REGIONS, REGION_NAMES, Region } from '../../../constants/regions';
2023-11-07 10:32:01 -06:00
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import fetchBlitz from '../../../core/blitz/fetchBlitz';
2023-10-31 15:20:37 -05:00
import { getAccountInfo } from '../../../core/blitz/getAccountInfo';
2023-10-31 15:31:03 -05:00
import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo';
import getRatingsInfo from '../../../core/blitz/getRatingsInfo';
2023-11-07 18:58:19 -06:00
import { getRatingsLeague } from '../../../core/blitz/getRatingsLeague';
2023-11-07 20:47:20 -06:00
import { getRatingsNeighbors } from '../../../core/blitz/getRatingsNeighbors';
2023-11-07 10:32:01 -06:00
import { AccountList } from '../../../core/blitz/searchPlayersAcrossRegions';
2023-11-07 17:24:51 -06:00
import { getArchivedLatestSeasonNumber } from '../../../core/blitzkrieg/getArchivedLatestSeasonNumber';
2023-11-07 11:36:08 -06:00
import getArchivedRatingsInfo from '../../../core/blitzkrieg/getArchivedRatingsInfo';
import { getArchivedRatingsLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingsLeaderboard';
2023-10-31 15:20:37 -05:00
import { noArrows } from './page.css';
2023-10-29 13:40:32 -05:00
2023-11-07 20:47:20 -06:00
const ROWS_OPTIONS = [5, 10, 15, 25, 30];
2023-10-31 15:20:37 -05:00
2023-11-07 11:13:30 -06:00
type UsernameCache = Record<Region, Record<number, string | null>>;
2023-11-07 17:24:51 -06:00
type ClanCache = Record<Region, Record<number, string | undefined>>;
2023-11-07 11:13:30 -06:00
const useUsernameCache = create<UsernameCache>(() => ({
2023-10-31 15:20:37 -05:00
asia: {},
com: {},
eu: {},
}));
2023-11-07 17:24:51 -06:00
const useClanCache = create<ClanCache>(() => ({
asia: {},
com: {},
eu: {},
}));
2023-10-31 13:15:28 -05:00
2023-10-29 13:40:32 -05:00
export default function Page() {
2023-11-07 18:58:19 -06:00
const [rowsPerPage, setRowsPerPage] = useState(25);
2023-10-31 15:20:37 -05:00
const usernameCache = useUsernameCache();
2023-10-31 15:31:03 -05:00
const clanCache = useClanCache();
2023-10-31 15:20:37 -05:00
const [region, setRegion] = useState<Region>('com');
2023-10-30 21:57:22 -05:00
// null being the latest season
const [season, setSeason] = useState<null | number>(null);
2023-10-31 13:30:47 -05:00
const { data: ratingsInfo } = useSWR(
2023-10-31 15:20:37 -05:00
`ratings-info-${region}-${season}`,
2023-10-31 13:30:47 -05:00
() =>
season === null
2023-10-31 15:20:37 -05:00
? getRatingsInfo(region)
2023-11-07 11:36:08 -06:00
: getArchivedRatingsInfo(region, season),
2023-10-31 13:15:28 -05:00
);
const [jumpToLeague, setJumpToLeague] = useState(0);
const { data: latestArchivedSeasonNumber } = useSWR<number>(
2023-11-07 17:24:51 -06:00
getArchivedLatestSeasonNumber.name,
getArchivedLatestSeasonNumber,
2023-10-31 13:15:28 -05:00
);
2023-11-07 20:47:20 -06:00
const [players, setPlayers] = useState<null | Record<
number,
BlitzkriegRatingsLeaderboardEntry
>>(null);
2023-10-31 15:20:37 -05:00
const [page, setPage] = useState(0);
2023-11-07 10:32:01 -06:00
const [highlightedPlayerId, setHighlightedPlayerId] = useState<number | null>(
null,
);
2023-11-07 10:47:28 -06:00
const positionInput = useRef<HTMLInputElement>(null);
2023-11-07 11:50:51 -06:00
const scoreInput = useRef<HTMLInputElement>(null);
2023-11-07 10:32:01 -06:00
const [searchResults, setSearchResults] = useState<AccountList | undefined>(
undefined,
);
const handleSearchPlayerChange = debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
if (trimmedSearch) {
const encodedSearch = encodeURIComponent(trimmedSearch);
const accountList = await fetchBlitz<AccountList>(
2023-11-07 11:40:46 -06:00
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}&limit=100`,
2023-11-07 10:32:01 -06:00
);
setSearchResults(
accountList.filter(
2023-11-07 20:47:20 -06:00
(searchedPlayer) => players && searchedPlayer.account_id in players,
2023-11-07 10:32:01 -06:00
),
);
} else {
setSearchResults(undefined);
}
},
500,
);
2023-10-31 15:20:37 -05:00
2023-11-07 18:58:19 -06:00
useEffect(() => {
2023-11-07 20:47:20 -06:00
// league caching
2023-11-07 18:58:19 -06:00
if (season === null) {
2023-11-07 20:47:20 -06:00
Promise.all(
range(0, 4).map(async (league: number) => {
const leaguePlayers = await getRatingsLeague(region, league);
useUsernameCache.setState(
produce((draft: UsernameCache) => {
leaguePlayers.result.forEach((player) => {
draft[region][player.spa_id] = player.nickname;
});
}),
);
useClanCache.setState(
produce((draft: ClanCache) => {
leaguePlayers.result.forEach((player) => {
draft[region][player.spa_id] = player.clan_tag;
});
}),
);
2023-10-31 15:20:37 -05:00
2023-11-07 20:47:20 -06:00
return leaguePlayers.result.reduce<
Record<number, BlitzkriegRatingsLeaderboardEntry>
>(
(accumulator, player) => ({
...accumulator,
[player.number - 1]: {
id: player.spa_id,
score: player.score,
} satisfies BlitzkriegRatingsLeaderboardEntry,
}),
{},
);
}),
).then((results) => {
const newPlayers = results.reduce<
Record<number, BlitzkriegRatingsLeaderboardEntry>
>(
(accumulator, result) => ({
...accumulator,
...result,
}),
{},
);
setPlayers(newPlayers);
});
} else {
getArchivedRatingsLeaderboard(region, season).then((result) =>
setPlayers({ ...result }),
);
}
}, []);
useEffect(() => {
// TODO: caches twice on initial load
if (!players || !ratingsInfo || ratingsInfo?.detail) return;
if (season === null) {
const middleIndex = Math.round(page * rowsPerPage + rowsPerPage / 2);
if (!(middleIndex in players)) return;
const middleId = players[middleIndex].id;
getRatingsNeighbors(region, middleId, rowsPerPage * 3).then(
({ neighbors }) => {
setPlayers(
produce((draft) => {
draft = draft ?? {};
neighbors.forEach((neighbor) => {
draft![neighbor.number - 1] = {
id: neighbor.spa_id,
score: neighbor.score,
} satisfies BlitzkriegRatingsLeaderboardEntry;
});
}),
);
useUsernameCache.setState(
produce((draft: UsernameCache) => {
neighbors.forEach((neighbor) => {
if (neighbor.nickname) {
draft[region][neighbor.spa_id] = neighbor.nickname;
}
});
}),
);
useClanCache.setState(
produce((draft: ClanCache) => {
neighbors.forEach((neighbor) => {
if (neighbor.clan_tag) {
draft[region][neighbor.spa_id] = neighbor.clan_tag;
}
});
}),
);
},
);
} else {
2023-11-07 18:58:19 -06:00
const ids = range(
2023-11-07 20:47:20 -06:00
Math.max(0, (page - 1) * rowsPerPage),
Math.min(ratingsInfo.count - 1, (page + 1) * rowsPerPage + rowsPerPage),
2023-11-07 18:58:19 -06:00
)
2023-11-07 20:47:20 -06:00
.map((index) => players![index].id)
2023-11-07 18:58:19 -06:00
.filter((id) => !(id in usernameCache[region]));
2023-10-31 15:31:03 -05:00
2023-11-07 18:58:19 -06:00
if (ids && ids.length > 0) {
getAccountInfo(region, ids).then((data) => {
data.map((player, index) => {
useUsernameCache.setState(
produce((draft: UsernameCache) => {
if (player) {
draft[region][ids[index]] = player.nickname;
} else {
draft[region][ids[index]] = null;
}
}),
);
});
2023-10-31 15:31:03 -05:00
});
2023-11-07 18:58:19 -06:00
getClanAccountInfo(region, ids, ['clan']).then((data) => {
data.map((player, index) => {
useClanCache.setState(
produce((draft: ClanCache) => {
if (player) {
draft[region][ids[index]] = player.clan?.tag;
}
}),
);
});
});
}
2023-10-31 15:20:37 -05:00
}
2023-11-07 20:47:20 -06:00
}, [page, players]);
2023-11-07 10:47:28 -06:00
const handleJumpToPosition = () => {
2023-11-07 20:47:20 -06:00
if (!players || !ratingsInfo || ratingsInfo?.detail) return;
2023-11-07 11:50:51 -06:00
2023-11-07 10:47:28 -06:00
const rawPosition = positionInput.current!.valueAsNumber - 1;
2023-11-07 18:58:19 -06:00
const position = Math.max(0, Math.min(rawPosition, ratingsInfo.count - 1));
2023-11-07 10:47:28 -06:00
setPage(Math.floor(position / rowsPerPage));
2023-11-07 20:47:20 -06:00
if (players) {
setHighlightedPlayerId(players[position].id);
2023-11-07 10:47:28 -06:00
}
};
const [jumpToPositionOpen, setJumpToPositionOpen] = useState(false);
2023-11-07 11:50:51 -06:00
const handleJumpToScore = () => {
2023-11-07 20:47:20 -06:00
if (!players || !ratingsInfo || ratingsInfo?.detail) return;
2023-11-07 11:50:51 -06:00
2023-11-07 18:58:19 -06:00
let playerIndex = -1;
for (let index = ratingsInfo.count - 1; index >= 0; index--) {
2023-11-07 20:47:20 -06:00
if (players[index].score >= scoreInput.current!.valueAsNumber) {
2023-11-07 18:58:19 -06:00
playerIndex = index;
break;
}
}
2023-11-07 11:50:51 -06:00
if (playerIndex === -1) return;
setPage(Math.floor(playerIndex / rowsPerPage));
2023-11-07 20:47:20 -06:00
if (players) {
setHighlightedPlayerId(players[playerIndex].id);
2023-11-07 11:50:51 -06:00
}
};
const [jumpToScoreOpen, setJumpToScoreOpen] = useState(false);
2023-11-07 18:58:19 -06:00
const pages = Math.ceil(
(ratingsInfo && ratingsInfo.detail === undefined ? ratingsInfo?.count : 1) /
rowsPerPage,
);
2023-10-31 15:20:37 -05:00
useEffect(() => {
setPage(0);
}, [season, region]);
2023-11-07 18:58:19 -06:00
useEffect(() => {
if (season === null && rowsPerPage > 30) setRowsPerPage(25);
}, [season]);
2023-10-29 13:40:32 -05:00
2023-11-07 10:06:15 -06:00
function PageTurner() {
const pageInput = useRef<HTMLInputElement>(null);
useEffect(() => {
if (pageInput.current) pageInput.current.value = `${page + 1}`;
}, [page]);
return (
2023-11-07 10:50:41 -06:00
<Flex justify="center" wrap="wrap" gap="2">
2023-11-07 11:50:51 -06:00
<Flex gap="2">
2023-11-07 18:58:19 -06:00
<Button
variant="soft"
onClick={() => setPage((page) => Math.max(page - 1, 0))}
2023-11-07 19:22:28 -06:00
disabled={page === 0}
2023-11-07 18:58:19 -06:00
>
2023-11-07 11:50:51 -06:00
<CaretLeftIcon />
</Button>
<TextField.Root className={noArrows}>
<TextField.Slot>Page</TextField.Slot>
<TextField.Input
className={noArrows}
type="number"
ref={pageInput}
style={{ width: 64, textAlign: 'center' }}
onBlur={(event) => {
setPage(
Math.max(
0,
2023-11-07 18:58:19 -06:00
Math.min(pages - 1, event.target.valueAsNumber - 1),
2023-11-07 11:50:51 -06:00
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
2023-11-07 18:58:19 -06:00
<TextField.Slot>out of {pages}</TextField.Slot>
2023-11-07 11:50:51 -06:00
</TextField.Root>
2023-11-07 18:58:19 -06:00
<Button
variant="soft"
onClick={() => {
setPage((page) => Math.min(page + 1, pages - 1));
}}
2023-11-07 19:22:28 -06:00
disabled={page === pages - 1}
2023-11-07 18:58:19 -06:00
>
2023-11-07 11:50:51 -06:00
<CaretRightIcon />
</Button>
</Flex>
2023-11-07 10:50:41 -06:00
</Flex>
2023-11-07 10:06:15 -06:00
);
}
2023-10-29 13:40:32 -05:00
return (
2023-11-07 10:32:01 -06:00
<PageWrapper color="orange">
2023-11-07 17:33:41 -06:00
<Flex gap="4" wrap="wrap" justify="center">
2023-11-07 18:58:19 -06:00
<Flex gap="2" wrap="wrap" justify="center">
2023-11-07 17:33:41 -06:00
<Select.Root
defaultValue={region}
onValueChange={(value) => setRegion(value as Region)}
>
<Select.Trigger style={{ flex: 1 }} />
2023-10-29 13:40:32 -05:00
2023-11-07 17:33:41 -06:00
<Select.Content>
{REGIONS.map((region) => (
<Select.Item key={region} value={region}>
{REGION_NAMES[region]}
</Select.Item>
))}
</Select.Content>
</Select.Root>
2023-10-30 21:57:22 -05:00
2023-11-07 17:33:41 -06:00
<Select.Root
value={`${season}`}
onValueChange={(value) =>
setSeason(value === 'null' ? null : parseInt(value))
}
>
<Select.Trigger style={{ flex: 1 }} />
2023-10-29 13:40:32 -05:00
2023-11-07 17:33:41 -06:00
<Select.Content>
{ratingsInfo?.detail === undefined && (
<Select.Item value="null">Current season</Select.Item>
)}
2023-10-30 21:57:22 -05:00
2023-11-07 17:33:41 -06:00
<Select.Group>
<Select.Label>Archives</Select.Label>
2023-10-30 21:57:22 -05:00
2023-11-07 17:33:41 -06:00
{latestArchivedSeasonNumber &&
range(
FIRST_ARCHIVED_RATINGS_SEASON,
latestArchivedSeasonNumber + 1,
)
.map((season) => (
<Select.Item key={season} value={`${season}`}>
Season {season}
2023-11-07 10:47:28 -06:00
</Select.Item>
2023-11-07 17:33:41 -06:00
))
.reverse()}
</Select.Group>
</Select.Content>
</Select.Root>
2023-11-07 10:47:28 -06:00
2023-11-07 17:33:41 -06:00
<Select.Root
onValueChange={(value) => setRowsPerPage(parseInt(value))}
2023-11-07 18:58:19 -06:00
value={`${rowsPerPage}`}
2023-11-07 17:33:41 -06:00
>
<Select.Trigger />
2023-11-07 11:02:11 -06:00
2023-11-07 17:33:41 -06:00
<Select.Content>
2023-11-07 20:47:20 -06:00
{ROWS_OPTIONS.map((size) => (
2023-11-07 17:33:41 -06:00
<Select.Item value={`${size}`} key={size}>
{size} per page
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Flex>
2023-11-07 11:02:11 -06:00
2023-11-07 17:33:41 -06:00
<Flex gap="3" justify="center" wrap="wrap" align="center">
<Text>Jump to:</Text>
<Dialog.Root open={jumpToScoreOpen} onOpenChange={setJumpToScoreOpen}>
<Dialog.Trigger>
<Button variant="ghost">Score</Button>
</Dialog.Trigger>
2023-11-07 10:47:28 -06:00
2023-11-07 17:33:41 -06:00
<Dialog.Content>
<Flex gap="4" justify="center">
2023-11-07 10:47:28 -06:00
<TextField.Input
2023-11-07 17:33:41 -06:00
ref={scoreInput}
2023-11-07 10:47:28 -06:00
onChange={handleSearchPlayerChange}
2023-11-07 17:33:41 -06:00
type="number"
placeholder="Type a score..."
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleJumpToScore();
setJumpToScoreOpen(false);
}
}}
2023-11-07 10:47:28 -06:00
/>
2023-11-07 17:33:41 -06:00
<Flex gap="2">
<Dialog.Close>
<Button color="red">Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button onClick={handleJumpToScore}>Jump</Button>
</Dialog.Close>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root
open={jumpToPositionOpen}
onOpenChange={setJumpToPositionOpen}
>
<Dialog.Trigger>
<Button variant="ghost">Position</Button>
</Dialog.Trigger>
<Dialog.Content>
<Flex gap="4" justify="center">
<TextField.Input
ref={positionInput}
onChange={handleSearchPlayerChange}
type="number"
placeholder="Type a position..."
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleJumpToPosition();
setJumpToPositionOpen(false);
}
}}
/>
<Flex gap="2">
<Dialog.Close>
<Button color="red">Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button onClick={handleJumpToPosition}>Jump</Button>
</Dialog.Close>
</Flex>
</Flex>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="ghost">League</Button>
</Dialog.Trigger>
<Dialog.Content>
<Flex gap="2" justify="center">
<Select.Root
value={`${jumpToLeague}`}
onValueChange={(value) => setJumpToLeague(parseInt(value))}
>
<Select.Trigger />
<Select.Content>
{ratingsInfo?.detail === undefined &&
ratingsInfo?.leagues.map(({ index, title }) => (
<Select.Item key={index} value={`${index}`}>
{title}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<Flex gap="2">
<Dialog.Close>
<Button color="red">Cancel</Button>
</Dialog.Close>
2023-11-07 10:47:28 -06:00
<Dialog.Close>
<Button
2023-11-07 18:58:19 -06:00
onClick={async () => {
if (season === null) {
// that's already done
const leaguePlayers = await getRatingsLeague(
region,
jumpToLeague,
);
2023-11-07 10:47:28 -06:00
2023-11-07 18:58:19 -06:00
useUsernameCache.setState(
produce((draft: UsernameCache) => {
leaguePlayers.result.forEach((player) => {
draft[region][player.spa_id] = player.nickname;
});
}),
);
useClanCache.setState(
produce((draft: ClanCache) => {
leaguePlayers.result.forEach((player) => {
draft[region][player.spa_id] = player.clan_tag;
});
}),
);
2023-11-07 17:33:41 -06:00
2023-11-07 18:58:19 -06:00
leaguePlayers.result.map(
(player) =>
({
id: player.spa_id,
score: player.score,
}) satisfies BlitzkriegRatingsLeaderboardEntry,
);
} else {
2023-11-07 20:47:20 -06:00
if (!ratingsInfo || ratingsInfo.detail || !players)
2023-11-07 18:58:19 -06:00
return;
const minScore =
LEAGUES[jumpToLeague - 1]?.minScore ?? Infinity;
let firstPlayerIndex = -1;
for (
let index = 0;
index < ratingsInfo.count;
index++
) {
2023-11-07 20:47:20 -06:00
if (players[index].score < minScore) {
2023-11-07 18:58:19 -06:00
firstPlayerIndex = index;
break;
}
}
if (firstPlayerIndex === -1) return;
setPage(Math.floor(firstPlayerIndex / rowsPerPage));
2023-11-07 20:47:20 -06:00
setHighlightedPlayerId(players[firstPlayerIndex].id);
2023-11-07 18:58:19 -06:00
}
2023-11-07 10:47:28 -06:00
}}
>
2023-11-07 17:33:41 -06:00
Jump
2023-11-07 10:47:28 -06:00
</Button>
</Dialog.Close>
2023-11-07 17:33:41 -06:00
</Flex>
2023-11-07 10:47:28 -06:00
</Flex>
2023-11-07 17:33:41 -06:00
</Dialog.Content>
</Dialog.Root>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="ghost">Player</Button>
</Dialog.Trigger>
2023-11-07 10:47:28 -06:00
2023-11-07 17:33:41 -06:00
<Dialog.Content>
<Flex gap="4" direction="column">
<TextField.Root>
<TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input
onChange={handleSearchPlayerChange}
placeholder="Search for player..."
/>
</TextField.Root>
<Flex direction="column" gap="2">
{searchResults?.map((player) => (
<Dialog.Close>
<Button
key={player.account_id}
variant="ghost"
onClick={() => {
2023-11-07 18:58:19 -06:00
if (!ratingsInfo || ratingsInfo.detail) return;
let playerIndex = -1;
for (
let index = 0;
index < ratingsInfo.count;
index++
) {
2023-11-07 20:47:20 -06:00
if (players![index].id === player.account_id) {
2023-11-07 18:58:19 -06:00
playerIndex = index;
break;
}
}
2023-11-07 17:33:41 -06:00
const playerPage = Math.floor(
playerIndex! / rowsPerPage,
);
setPage(playerPage);
setHighlightedPlayerId(player.account_id);
}}
>
{player.nickname}
</Button>
</Dialog.Close>
))}
{searchResults?.length === 0 && (
<Button disabled variant="ghost">
No players found in leaderboard
</Button>
)}
</Flex>
<Flex gap="2">
<Dialog.Close>
<Button color="red">Cancel</Button>
</Dialog.Close>
</Flex>
2023-11-07 10:47:28 -06:00
</Flex>
2023-11-07 17:33:41 -06:00
</Dialog.Content>
</Dialog.Root>
</Flex>
2023-10-31 15:20:37 -05:00
</Flex>
2023-11-07 10:50:41 -06:00
<PageTurner />
2023-10-31 15:20:37 -05:00
<Leaderboard.Root>
2023-11-07 20:47:20 -06:00
{players === null ? (
2023-11-07 10:06:15 -06:00
<Leaderboard.Gap message="Loading players..." />
) : (
2023-11-07 19:15:41 -06:00
range(
page * rowsPerPage,
Math.min(
page * rowsPerPage + rowsPerPage,
ratingsInfo?.detail ? 0 : (ratingsInfo?.count ?? 1) - 1,
),
).map((index) => (
<Leaderboard.Item
nickname={
2023-11-07 20:47:20 -06:00
index in players!
? usernameCache[region][players![index].id] === null
? `Deleted player ${players![index].id}`
: usernameCache[region][players![index].id] ??
2023-11-07 19:15:41 -06:00
'Loading player...'
: `Loading player...`
}
position={index + 1}
2023-11-07 20:47:20 -06:00
score={players![index]?.score ?? 0}
2023-11-07 19:15:41 -06:00
clan={
2023-11-07 20:47:20 -06:00
players![index]
? clanCache[region][players![index].id]
2023-11-07 19:15:41 -06:00
: undefined
}
key={index}
2023-11-07 20:47:20 -06:00
highlight={highlightedPlayerId === players![index]?.id}
2023-11-07 19:15:41 -06:00
/>
))
2023-11-07 10:06:15 -06:00
)}
2023-10-31 15:20:37 -05:00
</Leaderboard.Root>
2023-10-31 13:30:47 -05:00
2023-11-07 10:50:41 -06:00
<PageTurner />
2023-10-29 13:40:32 -05:00
</PageWrapper>
);
}