lots of cleanup and add player chart
This commit is contained in:
parent
a2401e6b57
commit
f9e665b2e6
@ -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
27
pnpm-lock.yaml
generated
@ -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:
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
10
src/app/components/card.tsx
Normal file
10
src/app/components/card.tsx
Normal 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>;
|
||||
}
|
20
src/app/components/chart/customized-axis-tick.tsx
Normal file
20
src/app/components/chart/customized-axis-tick.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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 />
|
||||
|
@ -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">
|
||||
|
@ -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}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
112
src/app/components/player/player-rank-chart.tsx
Normal file
112
src/app/components/player/player-rank-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
76
src/app/components/ui/card.tsx
Normal file
76
src/app/components/ui/card.tsx
Normal 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 }
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user