add leaderboard page
All checks were successful
deploy / deploy (push) Successful in 56s

This commit is contained in:
Lee
2023-10-28 18:00:11 +01:00
parent e3450e23b1
commit b9fd569196
14 changed files with 390 additions and 21 deletions

View File

@ -1,193 +0,0 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { normalizedRegionName } from "@/utils/utils";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import Card from "../Card";
import Container from "../Container";
import CountyFlag from "../CountryFlag";
import Pagination from "../Pagination";
import Spinner from "../Spinner";
import PlayerRanking from "./PlayerRanking";
import PlayerRankingMobile from "./PlayerRankingMobile";
const Error = dynamic(() => import("@/components/Error"));
type PageInfo = {
loading: boolean;
page: number;
totalPages: number;
players: ScoresaberPlayer[];
};
type GlobalRankingProps = {
page: number;
country?: string;
};
export default function GlobalRanking({ page, country }: GlobalRankingProps) {
const router = useRouter();
const searchQuery = useSearchParams();
const isMobile = searchQuery.get("mobile") == "true";
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [pageInfo, setPageInfo] = useState<PageInfo>({
loading: true,
page: page,
totalPages: 1,
players: [],
});
const updatePage = useCallback(
(page: any) => {
const windowSize = document.documentElement.clientWidth;
if (windowSize < 768 && !isMobile) {
router.push(`/ranking/global/${page}?mobile=true`);
router.refresh();
return;
}
console.log("Switching page to", page);
ScoreSaberAPI.fetchTopPlayers(page, country).then((response) => {
if (!response) {
setError(true);
setErrorMessage("No players found");
setPageInfo({ ...pageInfo, loading: false });
return;
}
setPageInfo({
...pageInfo,
players: response.players,
totalPages: response.pageInfo.totalPages,
loading: false,
page: page,
});
window.history.pushState(
{},
"",
country
? `/ranking/country/${country}/${page}`
: `/ranking/global/${page}`,
);
});
},
[country, isMobile, pageInfo, router],
);
useEffect(() => {
if (!pageInfo.loading || error) return;
updatePage(pageInfo.page);
}, [error, country, updatePage, pageInfo.page, pageInfo.loading]);
if (pageInfo.loading || error) {
return (
<main>
<Container>
<Card className="mt-2">
<div className="p-3 text-center">
<div role="status">
<div className="flex flex-col items-center justify-center gap-2">
{error && <Error errorMessage={errorMessage} />}
{!error && <Spinner />}
</div>
</div>
</div>
</Card>
</Container>
</main>
);
}
const players = pageInfo.players;
return (
<main>
<Container>
<Card className="mt-2">
{pageInfo.loading ? (
<div className="flex justify-center">
<Spinner />
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-md bg-gray-700 p-2">
{country && (
<CountyFlag countryCode={country} className="!h-8 !w-8" />
)}
<p>
You are viewing{" "}
{country
? "scores from " + normalizedRegionName(country)
: "Global scores"}
</p>
</div>
{!isMobile && (
<table className="table w-full table-auto border-spacing-2 border-none text-left">
<thead>
<tr>
<th className="px-4 py-2">Rank</th>
<th className="px-4 py-2">Profile</th>
<th className="px-4 py-2">Performance Points</th>
<th className="px-4 py-2">Total Plays</th>
<th className="px-4 py-2">Total Ranked Plays</th>
<th className="px-4 py-2">Avg Ranked Accuracy</th>
</tr>
</thead>
<tbody className="border-none">
{players.map((player) => (
<tr
key={player.rank}
className="border-b border-gray-700"
>
<PlayerRanking
showCountryFlag={country ? false : true}
player={player}
/>
</tr>
))}
</tbody>
</table>
)}
{isMobile && (
<div className="flex flex-col gap-2">
{players.map((player) => (
<div
key={player.rank}
className="flex flex-col gap-2 rounded-md bg-gray-700 hover:bg-gray-600"
>
<Link href={`/player/${player.id}/top/1`}>
<PlayerRankingMobile player={player} />
</Link>
</div>
))}
</div>
)}
{/* Pagination */}
<div className="flex w-full flex-row justify-center rounded-md bg-gray-800 md:flex-col">
<div className="p-3">
<Pagination
currentPage={pageInfo.page}
totalPages={pageInfo.totalPages}
onPageChange={(page) => {
updatePage(page);
}}
/>
</div>
</div>
</div>
)}
</Card>
</Container>
</main>
);
}

View File

@ -15,7 +15,8 @@ import {
} from "@heroicons/react/20/solid";
import clsx from "clsx";
import Image from "next/image";
import HeadsetIcon from "../HeadsetIcon";
import Link from "next/link";
import HeadsetIcon from "../icons/HeadsetIcon";
import ScoreStatLabel from "./ScoreStatLabel";
type ScoreProps = {
@ -76,13 +77,20 @@ export default function Score({ score, player, leaderboard }: ScoreProps) {
</div>
</div>
{/* Song Info */}
<div className="w-fit truncate text-blue-500">
<p className="font-bold">{leaderboard.songName}</p>
<p className="text-blue-300">
{leaderboard.songAuthorName}{" "}
<span className="text-gray-200">{leaderboard.levelAuthorName}</span>
</p>
</div>
<Link
href={`/leaderboard/${leaderboard.id}/1`}
className="transform-gpu transition-all hover:opacity-70"
>
<div className="w-fit truncate text-blue-500">
<p className="font-bold">{leaderboard.songName}</p>
<p className="text-blue-300">
{leaderboard.songAuthorName}{" "}
<span className="text-gray-200">
{leaderboard.levelAuthorName}
</span>
</p>
</div>
</Link>
</div>
<div className="flex items-center justify-between p-1 md:items-start md:justify-end">

View File

@ -1,93 +0,0 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { formatNumber } from "@/utils/number";
import { ScoreSaberAPI } from "@/utils/scoresaber/api";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import { useEffect, useState } from "react";
import Avatar from "../Avatar";
export default function SearchPlayer() {
const [search, setSearch] = useState("");
const [players, setPlayers] = useState([] as ScoresaberPlayer[]);
useEffect(() => {
// Don't search if the query is too short
if (search.length < 4) {
setPlayers([]); // Clear players
return;
}
searchPlayer(search);
}, [search]);
async function searchPlayer(search: string) {
// Check if the search is a profile link
if (search.startsWith("https://scoresaber.com/u/")) {
const id = search.split("/").pop();
if (id == undefined) return;
const player = await ScoreSaberAPI.fetchPlayerData(id);
if (player == undefined) return;
setPlayers([player]);
}
// Search by name
const players = await ScoreSaberAPI.searchByName(search);
if (players == undefined) return;
setPlayers(players);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
// Take the user to the first account
if (players.length > 0) {
window.location.href = `/player/${players[0].id}/top/1`;
}
}
return (
<form className="mt-6 flex gap-2" onSubmit={handleSubmit}>
<input
className="min-w-[14rem] border-b bg-transparent text-xs outline-none"
type="text"
placeholder="Enter a name or ScoreSaber profile..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button
className="transform-gpu rounded-md bg-blue-600 p-1 transition-all hover:opacity-80"
aria-label="Go to first player"
>
<MagnifyingGlassIcon className="font-black" width={18} height={18} />
</button>
<div
className={clsx(
"absolute z-20 mt-7 flex max-h-[200px] min-w-[14rem] flex-col divide-y overflow-y-auto rounded-md bg-gray-700 shadow-sm md:max-h-[300px]",
players.length > 0 ? "flex" : "hidden",
)}
>
{players.map((player: ScoresaberPlayer) => (
<a
key={player.id}
className="flex min-w-[14rem] items-center gap-2 rounded-md p-2 transition-all hover:bg-gray-600"
href={`/player/${player.id}/top/1`}
>
<Avatar label="Account" size={40} url={player.profilePicture} />
<div>
<p className="text-xs text-gray-400">
#{formatNumber(player.rank)}
</p>
<p className="text-sm">{player.name}</p>
</div>
</a>
))}
</div>
</form>
);
}