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-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
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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() {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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%;
|
--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"]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user