add global and country ranking cards on the player page
All checks were successful
Deploy / deploy (push) Successful in 2m23s
All checks were successful
Deploy / deploy (push) Successful in 2m23s
This commit is contained in:
@ -1,16 +1,17 @@
|
||||
type Props = {
|
||||
country: string;
|
||||
code: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export default function CountryFlag({ country, size = 24 }: Props) {
|
||||
export default function CountryFlag({ code, size = 24 }: Props) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt="Player Country"
|
||||
src={`/assets/flags/${country}.png`}
|
||||
src={`/assets/flags/${code}.png`}
|
||||
width={size * 2}
|
||||
height={size}
|
||||
className={`w-[${size * 2}px] h-[${size}px] object-contain`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token";
|
||||
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
||||
import { scoresaberService } from "@/common/service/impl/scoresaber";
|
||||
import { ScoreSort } from "@/common/service/score-sort";
|
||||
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
||||
import ScoreSaberPlayerScoresPageToken from "@/common/model/token/scoresaber/score-saber-player-scores-page-token";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Mini from "../ranking/mini";
|
||||
import PlayerHeader from "./player-header";
|
||||
import PlayerRankChart from "./player-rank-chart";
|
||||
import PlayerScores from "./player-scores";
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
@ -18,12 +18,7 @@ type Props = {
|
||||
page: number;
|
||||
};
|
||||
|
||||
export default function PlayerData({
|
||||
initialPlayerData: initalPlayerData,
|
||||
initialScoreData,
|
||||
sort,
|
||||
page,
|
||||
}: Props) {
|
||||
export default function PlayerData({ initialPlayerData: initalPlayerData, initialScoreData, sort, page }: Props) {
|
||||
let player = initalPlayerData;
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["player", player.id],
|
||||
@ -36,19 +31,16 @@ export default function PlayerData({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<PlayerHeader player={player} />
|
||||
{!player.inactive && (
|
||||
<>
|
||||
<PlayerRankChart player={player} />
|
||||
</>
|
||||
)}
|
||||
<PlayerScores
|
||||
initialScoreData={initialScoreData}
|
||||
player={player}
|
||||
sort={sort}
|
||||
page={page}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<article className="flex flex-col gap-2">
|
||||
<PlayerHeader player={player} />
|
||||
{!player.inactive && <>{/* <PlayerRankChart player={player} /> */}</>}
|
||||
<PlayerScores initialScoreData={initialScoreData} player={player} sort={sort} page={page} />
|
||||
</article>
|
||||
<aside className="w-[500px] hidden xl:flex flex-col gap-2">
|
||||
<Mini type="Global" player={player} />
|
||||
<Mini type="Country" player={player} />
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
||||
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||
import { formatNumberWithCommas, formatPp } from "@/common/number-utils";
|
||||
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
||||
import Card from "../card";
|
||||
import CountryFlag from "../country-flag";
|
||||
@ -20,7 +20,7 @@ const playerData = [
|
||||
{
|
||||
showWhenInactiveOrBanned: false,
|
||||
icon: (player: ScoreSaberPlayerToken) => {
|
||||
return <CountryFlag country={player.country.toLowerCase()} size={15} />;
|
||||
return <CountryFlag code={player.country.toLowerCase()} size={15} />;
|
||||
},
|
||||
render: (player: ScoreSaberPlayerToken) => {
|
||||
return <p>#{formatNumberWithCommas(player.countryRank)}</p>;
|
||||
@ -29,7 +29,7 @@ const playerData = [
|
||||
{
|
||||
showWhenInactiveOrBanned: true,
|
||||
render: (player: ScoreSaberPlayerToken) => {
|
||||
return <p className="text-pp">{formatNumberWithCommas(player.pp)}pp</p>;
|
||||
return <p className="text-pp">{formatPp(player.pp)}pp</p>;
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -50,20 +50,13 @@ export default function PlayerHeader({ player }: Props) {
|
||||
<p className="font-bold text-2xl">{player.name}</p>
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
{player.inactive && (
|
||||
<p className="text-gray-400">Inactive Account</p>
|
||||
)}
|
||||
{player.banned && (
|
||||
<p className="text-red-500">Banned Account</p>
|
||||
)}
|
||||
{player.inactive && <p className="text-gray-400">Inactive Account</p>}
|
||||
{player.banned && <p className="text-red-500">Banned Account</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{playerData.map((subName, index) => {
|
||||
// Check if the player is inactive or banned and if the data should be shown
|
||||
if (
|
||||
!subName.showWhenInactiveOrBanned &&
|
||||
(player.inactive || player.banned)
|
||||
) {
|
||||
if (!subName.showWhenInactiveOrBanned && (player.inactive || player.banned)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
131
src/components/ranking/mini.tsx
Normal file
131
src/components/ranking/mini.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { leaderboards } from "@/common/leaderboards";
|
||||
import ScoreSaberPlayerToken from "@/common/model/token/scoresaber/score-saber-player-token";
|
||||
import { ScoreSaberPlayersPageToken } from "@/common/model/token/scoresaber/score-saber-players-page-token";
|
||||
import { formatPp } from "@/common/number-utils";
|
||||
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ReactElement } from "react";
|
||||
import Card from "../card";
|
||||
import CountryFlag from "../country-flag";
|
||||
import { Avatar, AvatarImage } from "../ui/avatar";
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
type MiniProps = {
|
||||
type: "Global" | "Country";
|
||||
player: ScoreSaberPlayerToken;
|
||||
};
|
||||
|
||||
type Variants = {
|
||||
[key: string]: {
|
||||
itemsPerPage: number;
|
||||
icon: (player: ScoreSaberPlayerToken) => ReactElement;
|
||||
getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => number;
|
||||
query: (page: number, country: string) => Promise<ScoreSaberPlayersPageToken | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
const miniVariants: Variants = {
|
||||
Global: {
|
||||
itemsPerPage: 50,
|
||||
icon: () => <GlobeAmericasIcon className="w-6 h-6" />,
|
||||
getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => {
|
||||
return Math.floor((player.rank - 1) / itemsPerPage) + 1;
|
||||
},
|
||||
query: (page: number) => {
|
||||
return leaderboards.ScoreSaber.queries.lookupGlobalPlayers(page);
|
||||
},
|
||||
},
|
||||
Country: {
|
||||
itemsPerPage: 50,
|
||||
icon: (player: ScoreSaberPlayerToken) => {
|
||||
return <CountryFlag code={player.country} size={12} />;
|
||||
},
|
||||
getPage: (player: ScoreSaberPlayerToken, itemsPerPage: number) => {
|
||||
return Math.floor((player.countryRank - 1) / itemsPerPage) + 1;
|
||||
},
|
||||
query: (page: number, country: string) => {
|
||||
return leaderboards.ScoreSaber.queries.lookupGlobalPlayersByCountry(page, country);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function Mini({ type, player }: MiniProps) {
|
||||
const variant = miniVariants[type];
|
||||
const icon = variant.icon(player);
|
||||
|
||||
const itemsPerPage = variant.itemsPerPage;
|
||||
const page = variant.getPage(player, itemsPerPage);
|
||||
const rankWithinPage = player.rank % itemsPerPage;
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["player-" + type, player.id, type, page],
|
||||
queryFn: async () => {
|
||||
// Determine pages to search based on player's rank within the page
|
||||
const pagesToSearch = [page];
|
||||
if (rankWithinPage < 5 && page > 0) {
|
||||
// Player is near the start of the page, so search the previous page too
|
||||
pagesToSearch.push(page - 1);
|
||||
}
|
||||
if (rankWithinPage > itemsPerPage - 5) {
|
||||
// Player is near the end of the page, so search the next page too
|
||||
pagesToSearch.push(page + 1);
|
||||
}
|
||||
|
||||
// Fetch players from the determined pages
|
||||
const players: ScoreSaberPlayerToken[] = [];
|
||||
for (const p of pagesToSearch) {
|
||||
const response = await variant.query(p, player.country);
|
||||
if (response === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
players.push(...response.players);
|
||||
}
|
||||
|
||||
return players;
|
||||
},
|
||||
refetchInterval: REFRESH_INTERVAL,
|
||||
});
|
||||
|
||||
let players = data; // So we can update it later
|
||||
if (players && (!isLoading || !isError)) {
|
||||
// Find the player's position and show 3 players above and 1 below
|
||||
const playerPosition = players.findIndex((p) => p.id === player.id);
|
||||
players = players.slice(playerPosition - 3, playerPosition + 2);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full flex gap-2">
|
||||
<div className="flex gap-2">
|
||||
{icon}
|
||||
<p>{type} Ranking</p>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{isLoading && <p className="text-gray-400">Loading...</p>}
|
||||
{isError && <p className="text-red-500">Error</p>}
|
||||
{players?.map((player, index) => {
|
||||
const rank = type == "Global" ? player.rank : player.countryRank;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
href={`/player/${player.id}`}
|
||||
className="flex justify-between gap-2 bg-accent px-2 py-1.5 cursor-pointer transform-gpu transition-all hover:brightness-75 first:rounded-t last:rounded-b"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<p className="text-gray-400">#{rank}</p>
|
||||
<Avatar className="w-6 h-6 pointer-events-none">
|
||||
<AvatarImage alt="Profile Picture" src={player.profilePicture} />
|
||||
</Avatar>
|
||||
<p>{player.name}</p>
|
||||
</div>
|
||||
<p className="text-pp">{formatPp(player.pp)}pp</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -1,20 +1,17 @@
|
||||
import ScoreSaberLeaderboardToken from "@/common/model/token/scoresaber/score-saber-leaderboard-token";
|
||||
import ScoreSaberScoreToken from "@/common/model/token/scoresaber/score-saber-score-token";
|
||||
import { formatNumberWithCommas } from "@/common/number-utils";
|
||||
import { formatNumberWithCommas, formatPp } from "@/common/number-utils";
|
||||
import { accuracyToColor } from "@/common/song-utils";
|
||||
import StatValue from "@/components/stat-value";
|
||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import clsx from "clsx";
|
||||
import { accuracyToColor } from "@/common/song-utils";
|
||||
|
||||
type Badge = {
|
||||
name: string;
|
||||
color?: (
|
||||
score: ScoreSaberScoreToken,
|
||||
leaderboard: ScoreSaberLeaderboardToken,
|
||||
) => string | undefined;
|
||||
color?: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => string | undefined;
|
||||
create: (
|
||||
score: ScoreSaberScoreToken,
|
||||
leaderboard: ScoreSaberLeaderboardToken,
|
||||
leaderboard: ScoreSaberLeaderboardToken
|
||||
) => string | React.ReactNode | undefined;
|
||||
};
|
||||
|
||||
@ -29,22 +26,16 @@ const badges: Badge[] = [
|
||||
if (pp === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `${score.pp.toFixed(2)}pp`;
|
||||
return `${formatPp(pp)}pp`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Accuracy",
|
||||
color: (
|
||||
score: ScoreSaberScoreToken,
|
||||
leaderboard: ScoreSaberLeaderboardToken,
|
||||
) => {
|
||||
color: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
||||
return accuracyToColor(acc);
|
||||
},
|
||||
create: (
|
||||
score: ScoreSaberScoreToken,
|
||||
leaderboard: ScoreSaberLeaderboardToken,
|
||||
) => {
|
||||
create: (score: ScoreSaberScoreToken, leaderboard: ScoreSaberLeaderboardToken) => {
|
||||
const acc = (score.baseScore / leaderboard.maxScore) * 100;
|
||||
return `${acc.toFixed(2)}%`;
|
||||
},
|
||||
@ -70,16 +61,8 @@ const badges: Badge[] = [
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{fullCombo ? (
|
||||
<span className="text-green-400">FC</span>
|
||||
) : (
|
||||
formatNumberWithCommas(score.missedNotes)
|
||||
)}
|
||||
</p>
|
||||
<XMarkIcon
|
||||
className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")}
|
||||
/>
|
||||
<p>{fullCombo ? <span className="text-green-400">FC</span> : formatNumberWithCommas(score.missedNotes)}</p>
|
||||
<XMarkIcon className={clsx("w-5 h-5", fullCombo ? "hidden" : "text-red-400")} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { beatsaverService } from "@/common/service/impl/beatsaver";
|
||||
import ScoreSaberPlayerScoreToken from "@/common/model/token/scoresaber/score-saber-player-score-token";
|
||||
import BeatSaverMap from "@/common/database/types/beatsaver-map";
|
||||
import ScoreSaberPlayerScoreToken from "@/common/model/token/scoresaber/score-saber-player-score-token";
|
||||
import { beatsaverService } from "@/common/service/impl/beatsaver";
|
||||
import LeaderboardScores from "@/components/leaderboard/leaderboard-scores";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import ScoreButtons from "./score-buttons";
|
||||
@ -34,7 +34,7 @@ export default function Score({ playerScore }: Props) {
|
||||
return (
|
||||
<div className="pb-2 pt-2">
|
||||
<div
|
||||
className={`grid w-full gap-2 lg:gap-0 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.85fr_4fr_1fr_300px]`}
|
||||
className={`grid w-full gap-2 lg:gap-0 first:pt-0 last:pb-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]`}
|
||||
>
|
||||
<ScoreRankInfo score={score} />
|
||||
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||
|
Reference in New Issue
Block a user