more cleanup and added hover labels to player labels
All checks were successful
deploy / deploy (push) Successful in 58s

This commit is contained in:
Lee
2023-10-22 03:47:22 +01:00
parent a54b3d64fe
commit f03ac7809d
9 changed files with 31 additions and 13 deletions

View File

@ -0,0 +1,121 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { formatNumber } from "@/utils/number";
import {
CategoryScale,
Chart as ChartJS,
Legend,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip,
} from "chart.js";
import { Line } from "react-chartjs-2";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
);
type PlayerChartProps = {
className?: string;
scoresaber: ScoresaberPlayer;
};
export const options: any = {
maintainAspectRatio: false,
aspectRatio: 1,
interaction: {
mode: "index",
intersect: false,
},
scales: {
y: {
ticks: {
autoSkip: true,
maxTicksLimit: 4,
},
reverse: true,
},
x: {
ticks: {
autoSkip: true,
},
},
},
elements: {
point: {
radius: 0,
},
},
plugins: {
legend: {
position: "top" as const,
labels: {
color: "white",
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
label(context: any) {
switch (context.dataset.label) {
case "Rank": {
return `Rank #${formatNumber(context.parsed.y.toFixed(0))}`;
}
}
},
},
},
},
};
export default function PlayerChart({
className,
scoresaber,
}: PlayerChartProps) {
const history: number[] = scoresaber.histories
.split(",")
.map(function (item) {
return parseInt(item);
});
let labels = [];
for (let i = history.length; i > 0; i--) {
let label = `${i} days ago`;
if (i === 1) {
label = "now";
}
if (i === 2) {
label = "yesterday";
}
labels.push(label);
}
const data = {
labels,
datasets: [
{
lineTension: 0.4,
data: history,
label: "Rank",
borderColor: "#3e95cd",
fill: false,
color: "#fff",
},
],
};
return (
<div className="h-[280px] w-full">
<Line className={className} options={options} data={data} />
</div>
);
}

View File

@ -0,0 +1,168 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { usePlayerScoresStore } from "@/store/playerScoresStore";
import { useSettingsStore } from "@/store/settingsStore";
import { formatNumber } from "@/utils/number";
import { calcPpBoundary, getHighestPpPlay } from "@/utils/scoresaber/scores";
import { GlobeAsiaAustraliaIcon, HomeIcon } from "@heroicons/react/20/solid";
import { useRef } from "react";
import ReactCountryFlag from "react-country-flag";
import { toast } from "react-toastify";
import { useStore } from "zustand";
import Avatar from "../Avatar";
import Card from "../Card";
import Label from "../Label";
import PlayerChart from "./PlayerChart";
type PlayerInfoProps = {
playerData: ScoresaberPlayer;
};
export default function PlayerInfo({ playerData }: PlayerInfoProps) {
const playerId = playerData.id;
const settingsStore = useStore(useSettingsStore, (store) => store);
const playerScoreStore = useStore(usePlayerScoresStore, (store) => store);
// Whether we have scores for this player in the local database
const hasLocalScores = playerScoreStore?.exists(playerId);
const toastId: any = useRef(null);
async function claimProfile() {
settingsStore?.setUserId(playerId);
settingsStore?.refreshProfile();
const reponse = await playerScoreStore?.addPlayer(
playerId,
(page, totalPages) => {
const autoClose = page == totalPages ? 5000 : false;
if (page == 1) {
toastId.current = toast.info(
`Fetching scores ${page}/${totalPages}`,
{
autoClose: autoClose,
progress: page / totalPages,
},
);
} else {
toast.update(toastId.current, {
progress: page / totalPages,
render: `Fetching scores ${page}/${totalPages}`,
autoClose: autoClose,
});
}
console.log(`Fetching scores for ${playerId} (${page}/${totalPages})`);
},
);
if (reponse?.error) {
toast.error("Failed to claim profile");
console.log(reponse.message);
return;
}
toast.success("Successfully claimed profile");
}
return (
<Card className="mt-2">
{/* Player Info */}
<div className="flex flex-col items-center gap-3 md:flex-row md:items-start">
<div className="min-w-fit">
{/* Avatar */}
<div className="flex flex-col items-center gap-2">
<Avatar url={playerData.profilePicture} label="Avatar" />
</div>
{/* Settings Buttons */}
<div className="absolute right-3 top-20 flex flex-col justify-end gap-2 md:relative md:right-0 md:top-0 md:mt-2 md:flex-row md:justify-center">
{settingsStore?.userId !== playerId && (
<button>
<HomeIcon
title="Set as your Profile"
width={28}
height={28}
onClick={claimProfile}
/>
</button>
)}
</div>
</div>
<div className="mt-1 flex w-full flex-col items-center gap-2 md:items-start">
{/* Name */}
<p className="text-2xl">{playerData.name}</p>
<div className="flex gap-3 text-xl">
{/* Global Rank */}
<div className="flex items-center gap-1 text-gray-300">
<GlobeAsiaAustraliaIcon width={32} height={32} />
<p>#{playerData.rank}</p>
</div>
{/* Country Rank */}
<div className="flex items-center gap-1 text-gray-300">
<ReactCountryFlag
countryCode={playerData.country}
svg
className="!h-7 !w-7"
/>
<p>#{playerData.countryRank}</p>
</div>
{/* PP */}
<div className="flex items-center text-gray-300">
<p>{formatNumber(playerData.pp)}pp</p>
</div>
</div>
{/* Labels */}
<div className="flex flex-wrap justify-center gap-2 md:justify-start">
<Label
title="Total play count"
className="bg-blue-500"
hoverValue="Total ranked song play count"
value={formatNumber(playerData.scoreStats.totalPlayCount)}
/>
<Label
title="Total score"
className="bg-blue-500"
hoverValue="Total score of all your plays"
value={formatNumber(playerData.scoreStats.totalScore)}
/>
<Label
title="Avg ranked acc"
className="bg-blue-500"
hoverValue="Average accuracy of all your ranked plays"
value={`${playerData.scoreStats.averageRankedAccuracy.toFixed(
2,
)}%`}
/>
{hasLocalScores && (
<>
<Label
title="Top PP"
className="bg-[#8992e8]"
hoverValue="Highest pp play"
value={`${formatNumber(
getHighestPpPlay(playerId)?.toFixed(2),
)}pp`}
/>
<Label
title="+ 1pp"
className="bg-[#8992e8]"
hoverValue="Amount of raw pp required to increase your pp by 1pp"
value={`${formatNumber(
calcPpBoundary(playerId, 1)?.toFixed(2),
)}pp per raw`}
/>
</>
)}
</div>
{/* Chart */}
<PlayerChart scoresaber={playerData} />
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,105 @@
import { ScoresaberLeaderboardInfo } from "@/schemas/scoresaber/leaderboard";
import { ScoresaberScore } from "@/schemas/scoresaber/score";
import { formatNumber } from "@/utils/number";
import { GlobeAsiaAustraliaIcon } from "@heroicons/react/20/solid";
import clsx from "clsx";
import moment from "moment";
import Image from "next/image";
import ScoreStatLabel from "./ScoreStatLabel";
type ScoreProps = {
score: ScoresaberScore;
leaderboard: ScoresaberLeaderboardInfo;
};
export default function Score({ score, leaderboard }: ScoreProps) {
const isFullCombo = score.missedNotes + score.badCuts === 0;
return (
<div className="grid grid-cols-1 pb-2 pt-2 first:pt-0 last:pb-0 md:grid-cols-[1.1fr_6fr_3fr] xl:md:grid-cols-[.95fr_6fr_3fr]">
<div className="ml-3 flex flex-col items-start justify-center">
<div className="hidden w-fit flex-row items-center justify-start gap-1 md:flex">
<GlobeAsiaAustraliaIcon width={20} height={20} />
<p>#{score.rank}</p>
</div>
<p className="hidden text-sm text-gray-200 md:block">
{moment(score.timeSet).fromNow()}
</p>
</div>
{/* Song Image */}
<div className="flex w-full items-center gap-2">
<Image
src={leaderboard.coverImage}
alt={leaderboard.songName}
className="h-fit rounded-md"
width={60}
height={60}
/>
{/* Song Info */}
<div className="w-fit truncate text-blue-500">
<p>{leaderboard.songName}</p>
<p>
{leaderboard.songAuthorName}{" "}
<span className="text-gray-200">{leaderboard.levelAuthorName}</span>
</p>
</div>
</div>
<div className="flex items-center justify-between p-1 md:items-start md:justify-end">
<div className="flex flex-col md:hidden">
{/* Score rank */}
<div className="flex items-center gap-1">
<GlobeAsiaAustraliaIcon width={20} height={20} />
<p>#{score.rank}</p>
</div>
{/* Time Set (Mobile) */}
<div>
{" "}
<p className="block text-sm text-gray-200 md:hidden">
{moment(score.timeSet).fromNow()}
</p>
</div>
</div>
{/* PP */}
<div className="flex flex-col justify-end gap-2">
<div className="flex justify-end gap-2">
{score.pp > 0 && (
<ScoreStatLabel
className="bg-blue-500 text-center"
value={formatNumber(score.pp.toFixed(2)) + "pp"}
/>
)}
{/* Percentage score */}
<ScoreStatLabel
value={
!leaderboard.maxScore
? formatNumber(score.baseScore)
: ((score.baseScore / leaderboard.maxScore) * 100).toFixed(
2,
) + "%"
}
/>
</div>
<div className="flex justify-end gap-2">
{/* Missed Notes */}
<ScoreStatLabel
className={clsx(
"min-w-[2rem]",
isFullCombo ? "bg-green-500" : "bg-red-500",
)}
title={`${score.missedNotes} missed notes. ${score.badCuts} bad cuts.`}
value={
isFullCombo
? "FC"
: formatNumber(score.missedNotes + score.badCuts) + "x"
}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
import clsx from "clsx";
type LabelProps = {
value: string;
title?: string;
icon?: JSX.Element;
className?: string;
};
export default function ScoreStatLabel({
value,
title,
icon,
className = "bg-neutral-700",
}: LabelProps) {
return (
<div
className={clsx(
"flex flex-col rounded-md hover:cursor-default",
className,
)}
>
<div className="p4-[0.3rem] flex items-center gap-2 pb-[0.2rem] pl-[0.3rem] pr-[0.3rem] pt-[0.2rem]">
<p
className="flex w-full items-center justify-center gap-1"
title={title}
>
{value}
{icon}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,150 @@
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { ScoresaberPlayerScore } from "@/schemas/scoresaber/playerScore";
import { useSettingsStore } from "@/store/settingsStore";
import { SortType, SortTypes } from "@/types/SortTypes";
import { fetchScores } from "@/utils/scoresaber/api";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import Card from "../Card";
import Pagination from "../Pagination";
import { Spinner } from "../Spinner";
import Score from "./Score";
type PageInfo = {
loading: boolean;
page: number;
totalPages: number;
sortType: SortType;
scores: ScoresaberPlayerScore[];
};
type ScoresProps = {
playerData: ScoresaberPlayer;
page: number;
sortType: SortType;
};
export default function Scores({ playerData, page, sortType }: ScoresProps) {
const playerId = playerData.id;
const router = useRouter();
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [scores, setScores] = useState<PageInfo>({
loading: true,
page: page,
totalPages: 1,
sortType: sortType,
scores: [],
});
const updateScoresPage = useCallback(
(sortType: SortType, page: any) => {
console.log(`Switching page to ${page} with sort ${sortType.value}`);
fetchScores(playerId, page, sortType.value, 10).then((scoresResponse) => {
if (!scoresResponse) {
setError(true);
setErrorMessage("No Scores");
setScores({ ...scores, loading: false });
return;
}
setScores({
...scores,
scores: scoresResponse.scores,
totalPages: scoresResponse.pageInfo.totalPages,
loading: false,
page: page,
sortType: sortType,
});
useSettingsStore.setState({
lastUsedSortType: sortType,
});
if (page > 1) {
router.push(
`/player/${playerId}?page=${page}&sort=${sortType.value}`,
{
scroll: false,
},
);
} else {
router.push(`/player/${playerId}?sort=${sortType.value}`, {
scroll: false,
});
}
});
},
[playerId, router, scores],
);
useEffect(() => {
if (!scores.loading || error) return;
updateScoresPage(scores.sortType, scores.page);
}, [error, playerId, updateScoresPage, scores]);
return (
<Card className="mt-2 w-full items-center md:flex-col">
{/* Sort */}
<div className="m-2 w-full text-sm">
<div className="flex justify-center gap-2">
{Object.values(SortTypes).map((sortType) => {
return (
<button
key={sortType.value}
className={`flex transform-gpu flex-row items-center gap-1 rounded-md p-[0.35rem] transition-all hover:opacity-80 ${
scores.sortType.value === sortType.value
? "bg-blue-500"
: "bg-gray-500"
}`}
onClick={() => {
updateScoresPage(sortType, 1);
}}
>
{sortType.icon}
<p>{sortType.name}</p>
</button>
);
})}
</div>
</div>
<div className="w-full p-1">
{scores.loading ? (
<div className="flex justify-center">
<Spinner />
</div>
) : (
<div className="grid grid-cols-1 divide-y divide-gray-500">
{!scores.loading && scores.scores.length == 0 ? (
<p className="text-red-400">{errorMessage}</p>
) : (
scores.scores.map((scoreData, id) => {
const { score, leaderboard } = scoreData;
return (
<Score key={id} score={score} leaderboard={leaderboard} />
);
})
)}
</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={scores.page}
totalPages={scores.totalPages}
onPageChange={(page) => {
updateScoresPage(scores.sortType, page);
}}
/>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,90 @@
"use client";
import { ScoresaberPlayer } from "@/schemas/scoresaber/player";
import { formatNumber } from "@/utils/number";
import { getPlayerInfo, searchByName } 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 getPlayerInfo(id);
if (player == undefined) return;
setPlayers([player]);
}
// Search by name
const players = await 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}`;
}
}
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">
<MagnifyingGlassIcon className="font-black" width={18} height={18} />
</button>
<div
className={clsx(
"absolute z-20 mt-7 flex max-h-[400px] min-w-[14rem] flex-col divide-y overflow-y-auto rounded-md bg-gray-700 shadow-sm",
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}`}
>
<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>
);
}