- {item.icon &&
{item.icon}
}
-
{item.name}
+
+ {item.icon &&
{item.icon}
}
+
{item.name}
);
export default function Navbar() {
- const rightItem = items[items.length - 1];
+ const rightItems = items.filter(item => item.align === "right");
+ const leftItems = items.filter(item => item.align === "left");
return (
-
+
{/* Left-aligned items */}
- {items.slice(0, -1).map((item, index) => (
+ {leftItems.map((item, index) => (
{renderNavbarItem(item)}
))}
- {/* Right-aligned item */}
-
-
{renderNavbarItem(rightItem)}
-
+ {/* Right-aligned items */}
+
+ {rightItems.map((item, index) => (
+
+ {renderNavbarItem(item)}
+
+ ))}
+
);
diff --git a/apps/frontend/src/components/navbar/profile-button.tsx b/src/components/navbar/profile-button.tsx
similarity index 95%
rename from apps/frontend/src/components/navbar/profile-button.tsx
rename to src/components/navbar/profile-button.tsx
index f4353b9..74b0bbe 100644
--- a/apps/frontend/src/components/navbar/profile-button.tsx
+++ b/src/components/navbar/profile-button.tsx
@@ -27,7 +27,7 @@ export default function ProfileButton() {
src={`https://img.fascinated.cc/upload/w_24,h_24/https://cdn.scoresaber.com/avatars/${settings.playerId}.jpg`}
/>
-
You
+
You
);
diff --git a/apps/frontend/src/components/offline-network.tsx b/src/components/offline-network.tsx
similarity index 100%
rename from apps/frontend/src/components/offline-network.tsx
rename to src/components/offline-network.tsx
diff --git a/src/components/player/chart/generic-player-chart.tsx b/src/components/player/chart/generic-player-chart.tsx
new file mode 100644
index 0000000..21476b0
--- /dev/null
+++ b/src/components/player/chart/generic-player-chart.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { parseDate } from "@/common/time-utils";
+import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
+import React from "react";
+import GenericChart, { DatasetConfig } from "@/components/chart/generic-chart";
+import { getValueFromHistory } from "@/common/player-utils";
+
+type Props = {
+ /**
+ * The player the chart is for
+ */
+ player: ScoreSaberPlayer;
+
+ /**
+ * The data to render.
+ */
+ datasetConfig: DatasetConfig[];
+};
+
+// Set up the labels
+const labels: string[] = [];
+const historyDays = 50;
+for (let day = 0; day < historyDays; day++) {
+ if (day == 0) {
+ labels.push("Today");
+ } else if (day == 1) {
+ labels.push("Yesterday");
+ } else {
+ labels.push(`${day + 1} days ago`);
+ }
+}
+labels.reverse();
+
+export default function GenericPlayerChart({ player, datasetConfig }: Props) {
+ if (!player.statisticHistory || Object.keys(player.statisticHistory).length === 0) {
+ return (
+
+
Unable to load player rank chart, missing data...
+
+ );
+ }
+ const histories: Record
= {};
+
+ // Initialize histories for each dataset
+ datasetConfig.forEach(config => {
+ histories[config.field] = [];
+ });
+
+ // Sort the statistic entries by date
+ const statisticEntries = Object.entries(player.statisticHistory).sort(
+ ([a], [b]) => parseDate(a).getTime() - parseDate(b).getTime()
+ );
+
+ let previousDate: Date | null = null;
+
+ // Iterate through each statistic entry
+ for (const [dateString, history] of statisticEntries) {
+ const currentDate = parseDate(dateString);
+
+ // Fill in missing days with null values
+ if (previousDate) {
+ const diffDays = Math.floor((currentDate.getTime() - previousDate.getTime()) / (1000 * 60 * 60 * 24));
+ for (let i = 1; i < diffDays; i++) {
+ datasetConfig.forEach(config => {
+ histories[config.field].push(null);
+ });
+ }
+ }
+
+ // Push the historical data to histories
+ datasetConfig.forEach(config => {
+ histories[config.field].push(getValueFromHistory(history, config.field) ?? null);
+ });
+
+ previousDate = currentDate; // Update the previousDate for the next iteration
+ }
+
+ // Render the GenericChart with collected data
+ return ;
+}
diff --git a/src/components/player/chart/player-accuracy-chart.tsx b/src/components/player/chart/player-accuracy-chart.tsx
new file mode 100644
index 0000000..88c88a2
--- /dev/null
+++ b/src/components/player/chart/player-accuracy-chart.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
+import React from "react";
+import { DatasetConfig } from "@/components/chart/generic-chart";
+import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
+
+type Props = {
+ player: ScoreSaberPlayer;
+};
+
+// Dataset configuration for the chart
+const datasetConfig: DatasetConfig[] = [
+ {
+ title: "Average Ranked Accuracy",
+ field: "accuracy.averageRankedAccuracy",
+ color: "#606fff",
+ axisId: "y",
+ axisConfig: {
+ reverse: false,
+ display: true,
+ hideOnMobile: false,
+ displayName: "Average Ranked Accuracy",
+ position: "left",
+ },
+ labelFormatter: (value: number) => `Average Ranked Accuracy ${value.toFixed(2)}%`,
+ },
+];
+
+export default function PlayerAccuracyChart({ player }: Props) {
+ return ;
+}
diff --git a/src/components/player/chart/player-charts.tsx b/src/components/player/chart/player-charts.tsx
new file mode 100644
index 0000000..4e9d575
--- /dev/null
+++ b/src/components/player/chart/player-charts.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
+import PlayerRankingChart from "@/components/player/chart/player-ranking-chart";
+import { FC, useState } from "react";
+import Tooltip from "@/components/tooltip";
+import PlayerAccuracyChart from "@/components/player/chart/player-accuracy-chart";
+import { GlobeAmericasIcon } from "@heroicons/react/24/solid";
+import { TrendingUpIcon } from "lucide-react";
+
+type PlayerChartsProps = {
+ /**
+ * The player who the charts are for
+ */
+ player: ScoreSaberPlayer;
+};
+
+type SelectedChart = {
+ /**
+ * The index of the selected chart.
+ */
+ index: number;
+
+ /**
+ * The label of the selected chart.
+ */
+ label: string;
+
+ /**
+ * The icon of the selected chart.
+ */
+ icon: React.ReactNode;
+
+ /**
+ * The chart to render.
+ */
+ chart: FC;
+};
+
+export default function PlayerCharts({ player }: PlayerChartsProps) {
+ const charts: SelectedChart[] = [
+ {
+ index: 0,
+ label: "Ranking",
+ icon: ,
+ chart: PlayerRankingChart,
+ },
+ ];
+ if (player.isBeingTracked) {
+ charts.push({
+ index: 1,
+ label: "Accuracy",
+ icon: ,
+ chart: PlayerAccuracyChart,
+ });
+ }
+
+ const [selectedChart, setSelectedChart] = useState(charts[0]);
+
+ return (
+ <>
+ {selectedChart.chart({ player })}
+
+
+ {charts.length > 1 &&
+ charts.map(chart => {
+ const isSelected = chart.index === selectedChart.index;
+
+ return (
+
+ {chart.label} Chart
+ {isSelected ? "Currently Selected" : "Click to view"}
+
+ }
+ >
+
+
+ );
+ })}
+
+ >
+ );
+}
diff --git a/src/components/player/chart/player-ranking-chart.tsx b/src/components/player/chart/player-ranking-chart.tsx
new file mode 100644
index 0000000..ccdf18d
--- /dev/null
+++ b/src/components/player/chart/player-ranking-chart.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { formatNumberWithCommas } from "@/common/number-utils";
+import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
+import React from "react";
+import { DatasetConfig } from "@/components/chart/generic-chart";
+import GenericPlayerChart from "@/components/player/chart/generic-player-chart";
+
+type Props = {
+ player: ScoreSaberPlayer;
+};
+
+// Dataset configuration for the chart
+const datasetConfig: DatasetConfig[] = [
+ {
+ title: "Rank",
+ field: "rank",
+ color: "#3EC1D3",
+ axisId: "y",
+ axisConfig: {
+ reverse: true,
+ display: true,
+ displayName: "Global Rank",
+ position: "left",
+ },
+ labelFormatter: (value: number) => `Rank #${formatNumberWithCommas(value)}`,
+ },
+ {
+ title: "Country Rank",
+ field: "countryRank",
+ color: "#FFEA00",
+ axisId: "y1",
+ axisConfig: {
+ reverse: true,
+ display: false,
+ displayName: "Country Rank",
+ position: "left",
+ },
+ labelFormatter: (value: number) => `Country Rank #${formatNumberWithCommas(value)}`,
+ },
+ {
+ title: "PP",
+ field: "pp",
+ color: "#606fff",
+ axisId: "y2",
+ axisConfig: {
+ reverse: false,
+ display: true,
+ hideOnMobile: true,
+ displayName: "PP",
+ position: "right",
+ },
+ labelFormatter: (value: number) => `PP ${formatNumberWithCommas(value)}pp`,
+ },
+];
+
+export default function PlayerRankingChart({ player }: Props) {
+ return
;
+}
diff --git a/apps/frontend/src/components/player/claim-profile.tsx b/src/components/player/claim-profile.tsx
similarity index 100%
rename from apps/frontend/src/components/player/claim-profile.tsx
rename to src/components/player/claim-profile.tsx
diff --git a/apps/frontend/src/components/player/player-badges.tsx b/src/components/player/player-badges.tsx
similarity index 100%
rename from apps/frontend/src/components/player/player-badges.tsx
rename to src/components/player/player-badges.tsx
diff --git a/apps/frontend/src/components/player/player-data.tsx b/src/components/player/player-data.tsx
similarity index 73%
rename from apps/frontend/src/components/player/player-data.tsx
rename to src/components/player/player-data.tsx
index 9a83b06..27aff4f 100644
--- a/apps/frontend/src/components/player/player-data.tsx
+++ b/src/components/player/player-data.tsx
@@ -6,12 +6,14 @@ import { ScoreSort } from "@/common/model/score/score-sort";
import { useQuery } from "@tanstack/react-query";
import Mini from "../ranking/mini";
import PlayerHeader from "./player-header";
-import PlayerRankChart from "./player-rank-chart";
import PlayerScores from "./player-scores";
import ScoreSaberPlayer from "@/common/model/player/impl/scoresaber-player";
import Card from "@/components/card";
import PlayerBadges from "@/components/player/player-badges";
import { useIsMobile } from "@/hooks/use-is-mobile";
+import { useIsVisible } from "@/hooks/use-is-visible";
+import { useRef } from "react";
+import PlayerCharts from "@/components/player/chart/player-charts";
type Props = {
initialPlayerData: ScoreSaberPlayer;
@@ -22,16 +24,17 @@ type Props = {
};
export default function PlayerData({
- initialPlayerData: initalPlayerData,
+ initialPlayerData: initialPlayerData,
initialScoreData,
initialSearch,
sort,
page,
}: Props) {
const isMobile = useIsMobile();
- console.log("mobile", isMobile);
+ const miniRankingsRef = useRef
(null);
+ const isMiniRankingsVisible = useIsVisible(miniRankingsRef);
- let player = initalPlayerData;
+ let player = initialPlayerData;
const { data, isLoading, isError } = useQuery({
queryKey: ["player", player.id],
queryFn: () => scoresaberService.lookupPlayer(player.id),
@@ -49,7 +52,7 @@ export default function PlayerData({
{!player.inactive && (
-
+
)}
{!isMobile && (
-
diff --git a/apps/frontend/src/components/player/player-header.tsx b/src/components/player/player-header.tsx
similarity index 100%
rename from apps/frontend/src/components/player/player-header.tsx
rename to src/components/player/player-header.tsx
diff --git a/apps/frontend/src/components/player/player-scores.tsx b/src/components/player/player-scores.tsx
similarity index 87%
rename from apps/frontend/src/components/player/player-scores.tsx
rename to src/components/player/player-scores.tsx
index 22e6d3f..ddb76ae 100644
--- a/apps/frontend/src/components/player/player-scores.tsx
+++ b/src/components/player/player-scores.tsx
@@ -2,7 +2,7 @@ import { capitalizeFirstLetter } from "@/common/string-utils";
import useWindowDimensions from "@/hooks/use-window-dimensions";
import { ClockIcon, TrophyIcon, XMarkIcon } from "@heroicons/react/24/solid";
import { useQuery } from "@tanstack/react-query";
-import { motion, useAnimation, Variants } from "framer-motion";
+import { motion, useAnimation } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import Card from "../card";
import Pagination from "../input/pagination";
@@ -15,6 +15,7 @@ import { scoresaberService } from "@/common/service/impl/scoresaber";
import { Input } from "@/components/ui/input";
import { clsx } from "clsx";
import { useDebounce } from "@uidotdev/usehooks";
+import { scoreAnimation } from "@/components/score/score-animation";
type Props = {
initialScoreData?: ScoreSaberPlayerScoresPageToken;
@@ -42,12 +43,6 @@ const scoreSort = [
},
];
-const scoreAnimation: Variants = {
- hiddenRight: { opacity: 0, x: 50 },
- hiddenLeft: { opacity: 0, x: -50 },
- visible: { opacity: 1, x: 0, transition: { staggerChildren: 0.03 } },
-};
-
export default function PlayerScores({ initialScoreData, initialSearch, player, sort, page }: Props) {
const { width } = useWindowDimensions();
const controls = useAnimation();
@@ -57,8 +52,8 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
const [currentScores, setCurrentScores] = useState