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

View File

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

27
pnpm-lock.yaml generated
View File

@ -38,6 +38,9 @@ dependencies:
'@tanstack/react-query':
specifier: ^5.55.4
version: 5.55.4(react@18.3.1)
chart.js:
specifier: ^4.4.4
version: 4.4.4
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@ -65,6 +68,9 @@ dependencies:
react:
specifier: ^18
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:
specifier: ^18
version: 18.3.1(react@18.3.1)
@ -256,6 +262,10 @@ packages:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
/@kurkle/color@0.3.2:
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
dev: false
/@next/env@14.2.8:
resolution: {integrity: sha512-L44a+ynqkolyNBnYfF8VoCiSrjSZWgEHYKkKLGcs/a80qh7AkfVUD/MduVPgdsWZ31tgROR+yJRA0PZjSVBXWQ==}
dev: false
@ -1299,6 +1309,13 @@ packages:
supports-color: 7.2.0
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:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@ -2946,6 +2963,16 @@ packages:
/queue-microtask@1.2.3:
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):
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:

View File

@ -1,4 +1,3 @@
import ky from "ky";
import Leaderboard from "../leaderboard";
import ScoreSaberPlayer from "../types/scoresaber/scoresaber-player";
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`;
class ScoreSaberLeaderboard extends Leaderboard {
constructor() {
super("ScoreSaber");
}
/**
* 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
*/
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 {
const results = await ky
.get((useProxy ? "https://proxy.fascinated.cc/" : "") + SEARCH_PLAYERS_ENDPOINT.replace("{query}", query))
.json<ScoreSaberPlayerSearch>();
const results = await this.fetch<ScoreSaberPlayerSearch>(
useProxy,
SEARCH_PLAYERS_ENDPOINT.replace("{query}", query)
);
if (results.players.length === 0) {
return undefined;
}
@ -39,12 +43,9 @@ class ScoreSaberLeaderboard extends Leaderboard {
* @returns the player that matches the ID, or 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 {
const results = await ky
.get((useProxy ? "https://proxy.fascinated.cc/" : "") + LOOKUP_PLAYER_ENDPOINT.replace("{playerId}", playerId))
.json<ScoreSaberPlayer>();
return results;
return await this.fetch<ScoreSaberPlayer>(useProxy, LOOKUP_PLAYER_ENDPOINT.replace("{playerId}", playerId));
} catch {
return undefined;
}

View File

@ -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();
}
}

View File

@ -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>;
}

View File

@ -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>
);
};

View File

@ -35,7 +35,7 @@ const renderNavbarItem = (item: NavbarItem) => (
export default function Navbar() {
return (
<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 */}
<div className="flex items-center h-full">
<ProfileButton />

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import ScoreSaberPlayer from "@/app/common/leaderboard/types/scoresaber/scoresaber-player";
import { formatNumberWithCommas } from "@/app/common/number-utils";
import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
import Card from "../card";
import CountryFlag from "../country-flag";
import { Avatar, AvatarImage } from "../ui/avatar";
import ClaimProfile from "./claim-profile";
import PlayerSubName from "./player-sub-name";
import PlayerDataPoint from "./player-data-point";
const playerSubNames = [
{
@ -25,7 +26,7 @@ const playerSubNames = [
},
{
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) {
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">
<Avatar className="w-32 h-32 pointer-events-none">
<AvatarImage fetchPriority="high" src={player.profilePicture} />
@ -46,9 +47,9 @@ export default function PlayerHeader({ player }: Props) {
<div className="flex gap-2">
{playerSubNames.map((subName, index) => {
return (
<PlayerSubName icon={subName.icon && subName.icon(player)} key={index}>
<PlayerDataPoint icon={subName.icon && subName.icon(player)} key={index}>
{subName.render(player)}
</PlayerSubName>
</PlayerDataPoint>
);
})}
</div>
@ -57,6 +58,6 @@ export default function PlayerHeader({ player }: Props) {
</div>
</div>
</div>
</div>
</Card>
);
}

View File

@ -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>
);
}

View File

@ -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 }

View File

@ -81,36 +81,3 @@ body {
--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;
}
}

View File

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

View File

@ -21,6 +21,6 @@
"@/*": ["./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"]
}