diff --git a/package.json b/package.json index 35abf77..3a57816 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 316c12a..4a06f83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/app/common/leaderboard/impl/scoresaber.ts b/src/app/common/leaderboard/impl/scoresaber.ts index bc83917..0e91e8e 100644 --- a/src/app/common/leaderboard/impl/scoresaber.ts +++ b/src/app/common/leaderboard/impl/scoresaber.ts @@ -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 { - 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(); + const results = await this.fetch( + 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 { - 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(); - return results; + return await this.fetch(useProxy, LOOKUP_PLAYER_ENDPOINT.replace("{playerId}", playerId)); } catch { return undefined; } diff --git a/src/app/common/leaderboard/leaderboard.ts b/src/app/common/leaderboard/leaderboard.ts index 160dca4..828cc5f 100644 --- a/src/app/common/leaderboard/leaderboard.ts +++ b/src/app/common/leaderboard/leaderboard.ts @@ -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(useProxy: boolean, url: string): Promise { + return await ky + .get(this.buildRequestUrl(useProxy, url), { + next: { + revalidate: 60, // 1 minute + }, + }) + .json(); + } +} diff --git a/src/app/components/card.tsx b/src/app/components/card.tsx new file mode 100644 index 0000000..39bfb79 --- /dev/null +++ b/src/app/components/card.tsx @@ -0,0 +1,10 @@ +import clsx, { ClassValue } from "clsx"; + +type Props = { + children: React.ReactNode; + className?: ClassValue; +}; + +export default function Card({ children, className }: Props) { + return
{children}
; +} diff --git a/src/app/components/chart/customized-axis-tick.tsx b/src/app/components/chart/customized-axis-tick.tsx new file mode 100644 index 0000000..5c27a17 --- /dev/null +++ b/src/app/components/chart/customized-axis-tick.tsx @@ -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 ( + + + {payload.value} + + + ); +}; diff --git a/src/app/components/navbar/navbar.tsx b/src/app/components/navbar/navbar.tsx index 541334d..838c0d7 100644 --- a/src/app/components/navbar/navbar.tsx +++ b/src/app/components/navbar/navbar.tsx @@ -35,7 +35,7 @@ const renderNavbarItem = (item: NavbarItem) => ( export default function Navbar() { return (
-
+
{/* Left-aligned items */}
diff --git a/src/app/components/navbar/profile-button.tsx b/src/app/components/navbar/profile-button.tsx index 47a54c8..fc38a96 100644 --- a/src/app/components/navbar/profile-button.tsx +++ b/src/app/components/navbar/profile-button.tsx @@ -14,6 +14,10 @@ export default function ProfileButton() { return; // Settings hasn't loaded yet } + if (settings.playerId == null) { + return; // No player profile claimed + } + return ( diff --git a/src/app/components/player/player-sub-name.tsx b/src/app/components/player/player-data-point.tsx similarity index 72% rename from src/app/components/player/player-sub-name.tsx rename to src/app/components/player/player-data-point.tsx index 9fdc754..82ebde2 100644 --- a/src/app/components/player/player-sub-name.tsx +++ b/src/app/components/player/player-data-point.tsx @@ -3,7 +3,7 @@ type Props = { children: React.ReactNode; }; -export default function PlayerSubName({ icon, children }: Props) { +export default function PlayerDataPoint({ icon, children }: Props) { return (
{icon} diff --git a/src/app/components/player/player-data.tsx b/src/app/components/player/player-data.tsx index a3afc49..11c2e92 100644 --- a/src/app/components/player/player-data.tsx +++ b/src/app/components/player/player-data.tsx @@ -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 (
+
); } diff --git a/src/app/components/player/player-header.tsx b/src/app/components/player/player-header.tsx index 918e0f2..81b036d 100644 --- a/src/app/components/player/player-header.tsx +++ b/src/app/components/player/player-header.tsx @@ -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

{formatNumberWithCommas(player.pp)}pp

; + return

{formatNumberWithCommas(player.pp)}pp

; }, }, ]; @@ -36,7 +37,7 @@ type Props = { export default function PlayerHeader({ player }: Props) { return ( -
+
@@ -46,9 +47,9 @@ export default function PlayerHeader({ player }: Props) {
{playerSubNames.map((subName, index) => { return ( - + {subName.render(player)} - + ); })}
@@ -57,6 +58,6 @@ export default function PlayerHeader({ player }: Props) {
-
+ ); } diff --git a/src/app/components/player/player-rank-chart.tsx b/src/app/components/player/player-rank-chart.tsx new file mode 100644 index 0000000..1244acf --- /dev/null +++ b/src/app/components/player/player-rank-chart.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/components/ui/card.tsx b/src/app/components/ui/card.tsx new file mode 100644 index 0000000..76cca06 --- /dev/null +++ b/src/app/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/app/common/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/app/globals.css b/src/app/globals.css index a849871..f7fcb4d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; - } -} diff --git a/tailwind.config.ts b/tailwind.config.ts index 3efdbe6..b67ee41 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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: { diff --git a/tsconfig.json b/tsconfig.json index 7b28589..0effe2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }