lots of cleanup and add player chart

This commit is contained in:
Lee 2024-09-11 15:38:04 +01:00
parent a2401e6b57
commit f9e665b2e6
16 changed files with 324 additions and 54 deletions

@ -20,6 +20,7 @@
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-query": "^5.55.4", "@tanstack/react-query": "^5.55.4",
"chart.js": "^4.4.4",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.8", "dexie": "^4.0.8",
@ -29,6 +30,7 @@
"next": "14.2.8", "next": "14.2.8",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18", "react": "^18",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",

27
pnpm-lock.yaml generated

@ -38,6 +38,9 @@ dependencies:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.55.4 specifier: ^5.55.4
version: 5.55.4(react@18.3.1) version: 5.55.4(react@18.3.1)
chart.js:
specifier: ^4.4.4
version: 4.4.4
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@ -65,6 +68,9 @@ dependencies:
react: react:
specifier: ^18 specifier: ^18
version: 18.3.1 version: 18.3.1
react-chartjs-2:
specifier: ^5.2.0
version: 5.2.0(chart.js@4.4.4)(react@18.3.1)
react-dom: react-dom:
specifier: ^18 specifier: ^18
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
@ -256,6 +262,10 @@ packages:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
/@kurkle/color@0.3.2:
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
dev: false
/@next/env@14.2.8: /@next/env@14.2.8:
resolution: {integrity: sha512-L44a+ynqkolyNBnYfF8VoCiSrjSZWgEHYKkKLGcs/a80qh7AkfVUD/MduVPgdsWZ31tgROR+yJRA0PZjSVBXWQ==} resolution: {integrity: sha512-L44a+ynqkolyNBnYfF8VoCiSrjSZWgEHYKkKLGcs/a80qh7AkfVUD/MduVPgdsWZ31tgROR+yJRA0PZjSVBXWQ==}
dev: false dev: false
@ -1299,6 +1309,13 @@ packages:
supports-color: 7.2.0 supports-color: 7.2.0
dev: true dev: true
/chart.js@4.4.4:
resolution: {integrity: sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==}
engines: {pnpm: '>=8'}
dependencies:
'@kurkle/color': 0.3.2
dev: false
/chokidar@3.6.0: /chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@ -2946,6 +2963,16 @@ packages:
/queue-microtask@1.2.3: /queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
/react-chartjs-2@5.2.0(chart.js@4.4.4)(react@18.3.1):
resolution: {integrity: sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==}
peerDependencies:
chart.js: ^4.1.1
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
chart.js: 4.4.4
react: 18.3.1
dev: false
/react-dom@18.3.1(react@18.3.1): /react-dom@18.3.1(react@18.3.1):
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies: peerDependencies:

@ -1,4 +1,3 @@
import ky from "ky";
import Leaderboard from "../leaderboard"; import Leaderboard from "../leaderboard";
import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player"; import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player";
import { ScoreSaberPlayerSearch } from "../types/scoresaber/scoresaber-player-search"; import { ScoreSaberPlayerSearch } from "../types/scoresaber/scoresaber-player-search";
@ -8,6 +7,10 @@ const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search={query}`;
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/{playerId}/full`; const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/{playerId}/full`;
class ScoreSaberLeaderboard extends Leaderboard { class ScoreSaberLeaderboard extends Leaderboard {
constructor() {
super("ScoreSaber");
}
/** /**
* Gets the players that match the query. * Gets the players that match the query.
* *
@ -16,11 +19,12 @@ class ScoreSaberLeaderboard extends Leaderboard {
* @returns the players that match the query, or undefined if no players were found * @returns the players that match the query, or undefined if no players were found
*/ */
async searchPlayers(query: string, useProxy = true): Promise<ScoreSaberPlayerSearch | undefined> { async searchPlayers(query: string, useProxy = true): Promise<ScoreSaberPlayerSearch | undefined> {
console.log(`SS API: Searching for players matching "${query}"...`); this.log(`Searching for players matching "${query}"...`);
try { try {
const results = await ky const results = await this.fetch<ScoreSaberPlayerSearch>(
.get((useProxy ? "https://proxy.fascinated.cc/" : "") + SEARCH_PLAYERS_ENDPOINT.replace("{query}", query)) useProxy,
.json<ScoreSaberPlayerSearch>(); SEARCH_PLAYERS_ENDPOINT.replace("{query}", query)
);
if (results.players.length === 0) { if (results.players.length === 0) {
return undefined; return undefined;
} }
@ -39,12 +43,9 @@ class ScoreSaberLeaderboard extends Leaderboard {
* @returns the player that matches the ID, or undefined * @returns the player that matches the ID, or undefined
*/ */
async lookupPlayer(playerId: string, useProxy = true): Promise<ScoreSaberPlayer | undefined> { async lookupPlayer(playerId: string, useProxy = true): Promise<ScoreSaberPlayer | undefined> {
console.log(`SS API: Looking up player "${playerId}"...`); this.log(`Looking up player "${playerId}"...`);
try { try {
const results = await ky return await this.fetch<ScoreSaberPlayer>(useProxy, LOOKUP_PLAYER_ENDPOINT.replace("{playerId}", playerId));
.get((useProxy ? "https://proxy.fascinated.cc/" : "") + LOOKUP_PLAYER_ENDPOINT.replace("{playerId}", playerId))
.json<ScoreSaberPlayer>();
return results;
} catch { } catch {
return undefined; return undefined;
} }

@ -1 +1,49 @@
export default class Leaderboard {} import ky from "ky";
export default class Leaderboard {
/**
* The name of the leaderboard.
*/
private name: string;
constructor(name: string) {
this.name = name;
}
/**
* Logs a message to the console.
*
* @param data the data to log
*/
public log(data: unknown) {
console.log(`[${this.name}]: ${data}`);
}
/**
* Builds a request url.
*
* @param useProxy whether to use proxy or not
* @param url the url to fetch
* @returns the request url
*/
private buildRequestUrl(useProxy: boolean, url: string): string {
return (useProxy ? "https://proxy.fascinated.cc/" : "") + url;
}
/**
* Fetches data from the given url.
*
* @param useProxy whether to use proxy or not
* @param url the url to fetch
* @returns the fetched data
*/
public async fetch<T>(useProxy: boolean, url: string): Promise<T> {
return await ky
.get<T>(this.buildRequestUrl(useProxy, url), {
next: {
revalidate: 60, // 1 minute
},
})
.json();
}
}

@ -0,0 +1,10 @@
import clsx, { ClassValue } from "clsx";
type Props = {
children: React.ReactNode;
className?: ClassValue;
};
export default function Card({ children, className }: Props) {
return <div className={clsx("flex flex-col bg-secondary/90 p-3 rounded-md", className)}>{children}</div>;
}

@ -0,0 +1,20 @@
export const CustomizedAxisTick = ({
x,
y,
payload,
rotateAngle = -45,
}: {
x?: number;
y?: number;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload?: any;
rotateAngle?: number;
}) => {
return (
<g transform={`translate(${x},${y})`}>
<text x={0} y={0} dy={16} textAnchor="end" fill="#666" transform={`rotate(${rotateAngle})`}>
{payload.value}
</text>
</g>
);
};

@ -35,7 +35,7 @@ const renderNavbarItem = (item: NavbarItem) => (
export default function Navbar() { export default function Navbar() {
return ( return (
<div className="w-full py-2"> <div className="w-full py-2">
<div className="h-10 rounded-md items-center flex justify-between bg-secondary"> <div className="h-10 rounded-md items-center flex justify-between bg-secondary/90">
{/* Left-aligned items */} {/* Left-aligned items */}
<div className="flex items-center h-full"> <div className="flex items-center h-full">
<ProfileButton /> <ProfileButton />

@ -14,6 +14,10 @@ export default function ProfileButton() {
return; // Settings hasn't loaded yet return; // Settings hasn't loaded yet
} }
if (settings.playerId == null) {
return; // No player profile claimed
}
return ( return (
<NavbarButton> <NavbarButton>
<Link href={`/player/${settings.playerId}`} className="flex items-center gap-2"> <Link href={`/player/${settings.playerId}`} className="flex items-center gap-2">

@ -3,7 +3,7 @@ type Props = {
children: React.ReactNode; children: React.ReactNode;
}; };
export default function PlayerSubName({ icon, children }: Props) { export default function PlayerDataPoint({ icon, children }: Props) {
return ( return (
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
{icon} {icon}

@ -4,6 +4,7 @@ import { scoresaberLeaderboard } from "@/app/common/leaderboard/impl/scoresaber"
import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player"; import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import PlayerHeader from "./player-header"; import PlayerHeader from "./player-header";
import PlayerRankChart from "./player-rank-chart";
const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
@ -26,6 +27,7 @@ export default function PlayerData({ initalPlayerData }: Props) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<PlayerHeader player={player} /> <PlayerHeader player={player} />
<PlayerRankChart player={player} />
</div> </div>
); );
} }

@ -1,10 +1,11 @@
import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player"; import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player";
import { formatNumberWithCommas } from "@/app/common/number-utils"; import { formatNumberWithCommas } from "@/app/common/number-utils";
import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
import Card from "../card";
import CountryFlag from "../country-flag"; import CountryFlag from "../country-flag";
import { Avatar, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarImage } from "../ui/avatar";
import ClaimProfile from "./claim-profile"; import ClaimProfile from "./claim-profile";
import PlayerSubName from "./player-sub-name"; import PlayerDataPoint from "./player-data-point";
const playerSubNames = [ const playerSubNames = [
{ {
@ -25,7 +26,7 @@ const playerSubNames = [
}, },
{ {
render: (player: ScoreSaberPlayer) => { render: (player: ScoreSaberPlayer) => {
return <p className="tex-pp">{formatNumberWithCommas(player.pp)}pp</p>; return <p className="text-pp">{formatNumberWithCommas(player.pp)}pp</p>;
}, },
}, },
]; ];
@ -36,7 +37,7 @@ type Props = {
export default function PlayerHeader({ player }: Props) { export default function PlayerHeader({ player }: Props) {
return ( return (
<div className="flex flex-col bg-secondary p-2 rounded-md"> <Card>
<div className="flex gap-3 flex-col items-center text-center sm:flex-row sm:items-start sm:text-start relative select-none"> <div className="flex gap-3 flex-col items-center text-center sm:flex-row sm:items-start sm:text-start relative select-none">
<Avatar className="w-32 h-32 pointer-events-none"> <Avatar className="w-32 h-32 pointer-events-none">
<AvatarImage fetchPriority="high" src={player.profilePicture} /> <AvatarImage fetchPriority="high" src={player.profilePicture} />
@ -46,9 +47,9 @@ export default function PlayerHeader({ player }: Props) {
<div className="flex gap-2"> <div className="flex gap-2">
{playerSubNames.map((subName, index) => { {playerSubNames.map((subName, index) => {
return ( return (
<PlayerSubName icon={subName.icon && subName.icon(player)} key={index}> <PlayerDataPoint icon={subName.icon && subName.icon(player)} key={index}>
{subName.render(player)} {subName.render(player)}
</PlayerSubName> </PlayerDataPoint>
); );
})} })}
</div> </div>
@ -57,6 +58,6 @@ export default function PlayerHeader({ player }: Props) {
</div> </div>
</div> </div>
</div> </div>
</div> </Card>
); );
} }

@ -0,0 +1,112 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player";
import { formatNumberWithCommas } from "@/app/common/number-utils";
import { CategoryScale, Chart, Legend, LinearScale, LineElement, PointElement, Title, Tooltip } from "chart.js";
import { Line } from "react-chartjs-2";
import Card from "../card";
Chart.register(LinearScale, CategoryScale, PointElement, LineElement, Title, Tooltip, Legend);
export const options: any = {
maintainAspectRatio: false,
aspectRatio: 1,
interaction: {
mode: "index",
intersect: false,
},
scales: {
y: {
ticks: {
autoSkip: true,
maxTicksLimit: 8,
stepSize: 1,
},
grid: {
// gray grid lines
color: "#252525",
},
reverse: true,
},
x: {
ticks: {
autoSkip: true,
},
grid: {
// gray grid lines
color: "#252525",
},
},
},
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 #${formatNumberWithCommas(Number(context.parsed.y))}`;
}
}
},
},
},
},
};
type Props = {
player: ScoreSaberPlayer;
};
export default function PlayerRankChart({ player }: Props) {
const playerRankHistory = player.histories.split(",").map((value) => {
return parseInt(value);
});
playerRankHistory.push(player.rank);
const labels = [];
for (let i = playerRankHistory.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.5,
data: playerRankHistory,
label: "Rank",
borderColor: "#c084fc",
fill: false,
color: "#fff",
},
],
};
return (
<Card className="h-96">
<Line options={options} data={data} />
</Card>
);
}

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/app/common/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

@ -81,36 +81,3 @@ body {
--chart-5: 340 75% 55%; --chart-5: 340 75% 55%;
} }
} }
@layer base {
* {
@apply border-border;
/* Scrollbar (Firefox) */
scrollbar-color: hsl(var(--accent)) hsl(var(--background));
scrollbar-width: thin;
}
body {
@apply bg-background text-foreground;
}
}
/* Scrollbar (Chrome & Safari) */
@layer base {
::-webkit-scrollbar {
@apply w-1.5;
}
::-webkit-scrollbar-track {
@apply bg-inherit;
}
::-webkit-scrollbar-thumb {
@apply bg-accent rounded-3xl;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-opacity-80;
}
}

@ -10,7 +10,7 @@ const config: Config = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
pp: "text-purple-400", pp: "#c084fc",
background: "hsl(var(--background))", background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))", foreground: "hsl(var(--foreground))",
card: { card: {

@ -21,6 +21,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tailwind.config.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }