diff --git a/projects/backend/src/service/player.service.ts b/projects/backend/src/service/player.service.ts index acda106..3ea68cb 100644 --- a/projects/backend/src/service/player.service.ts +++ b/projects/backend/src/service/player.service.ts @@ -24,12 +24,15 @@ export class PlayerService { } console.log(`Creating player "${id}"...`); - player = (await PlayerModel.create({ _id: id })) as PlayerDocument; - if (player === null) { - throw new InternalServerError(`Failed to create player document for "${id}"`); + try { + player = (await PlayerModel.create({ _id: id })) as PlayerDocument; + player.trackedSince = new Date(); + await this.seedPlayerHistory(player, playerToken); + } catch (err) { + const message = `Failed to create player document for "${id}"`; + console.log(message, err); + throw new InternalServerError(message); } - player.trackedSince = new Date(); - await this.seedPlayerHistory(player, playerToken); } return player; } diff --git a/projects/common/src/types/player/impl/scoresaber-player.ts b/projects/common/src/types/player/impl/scoresaber-player.ts index fc4c7bc..d20cc93 100644 --- a/projects/common/src/types/player/impl/scoresaber-player.ts +++ b/projects/common/src/types/player/impl/scoresaber-player.ts @@ -48,6 +48,11 @@ export default interface ScoreSaberPlayer extends Player { */ permissions: number; + /** + * The pages for the players positions. + */ + rankPages: ScoreSaberRankPages; + /** * Whether the player is banned or not. */ @@ -167,6 +172,10 @@ export async function getScoreSaberPlayerFromToken( const countryRankChange = getChange("countryRank"); const ppChange = getChange("pp"); + const getRankPosition = (rank: number): number => { + return Math.floor(rank / 50) + 1; + }; + return { id: token.id, name: token.name, @@ -186,6 +195,10 @@ export async function getScoreSaberPlayerFromToken( badges: badges, statisticHistory: statisticHistory, statistics: token.scoreStats, + rankPages: { + global: getRankPosition(token.rank), + country: getRankPosition(token.countryRank), + }, permissions: token.permissions, banned: token.banned, inactive: token.inactive, @@ -262,3 +275,15 @@ export type ScoreSaberPlayerStatistics = { */ replaysWatched: number; }; + +export type ScoreSaberRankPages = { + /** + * Their page for their global rank position. + */ + global: number; + + /** + * Their page for their country rank position. + */ + country: number; +}; diff --git a/projects/common/src/utils/region-utils.ts b/projects/common/src/utils/region-utils.ts new file mode 100644 index 0000000..632a4f1 --- /dev/null +++ b/projects/common/src/utils/region-utils.ts @@ -0,0 +1,11 @@ +let regionNames = new Intl.DisplayNames(["en"], { type: "region" }); + +/** + * Returns the normalized region name + * + * @param region the region to normalize + * @returns the normalized region name + */ +export function normalizedRegionName(region: string) { + return regionNames.of(region); +} diff --git a/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx b/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx index cd74385..67584c1 100644 --- a/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/leaderboard/[...slug]/page.tsx @@ -42,7 +42,7 @@ const getLeaderboardData = async ({ params }: Props, fetchScores: boolean = true const id = slug[0]; // The leaderboard id const page = parseInt(slug[1]) || 1; // The page number - const cacheId = `${id}-${page}`; + const cacheId = `${id}-${page}-${fetchScores}`; if (leaderboardCache.has(cacheId)) { return leaderboardCache.get(cacheId) as LeaderboardData; } diff --git a/projects/website/src/app/(pages)/player/[...slug]/page.tsx b/projects/website/src/app/(pages)/player/[...slug]/page.tsx index f19057c..b44ff3f 100644 --- a/projects/website/src/app/(pages)/player/[...slug]/page.tsx +++ b/projects/website/src/app/(pages)/player/[...slug]/page.tsx @@ -51,7 +51,7 @@ const getPlayerData = async ({ params }: Props, fetchScores: boolean = true): Pr const page = parseInt(slug[2]) || 1; // The page number const search = (slug[3] as string) || ""; // The search query - const cacheId = `${id}-${sort}-${page}-${search}`; + const cacheId = `${id}-${sort}-${page}-${search}-${fetchScores}`; if (playerCache.has(cacheId)) { return playerCache.get(cacheId) as PlayerData; } diff --git a/projects/website/src/app/(pages)/ranking/[[...slug]]/page.tsx b/projects/website/src/app/(pages)/ranking/[[...slug]]/page.tsx new file mode 100644 index 0000000..0c3ce64 --- /dev/null +++ b/projects/website/src/app/(pages)/ranking/[[...slug]]/page.tsx @@ -0,0 +1,109 @@ +import { Metadata } from "next"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import NodeCache from "node-cache"; +import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token"; +import Card from "@/components/card"; +import RankingData from "@/components/ranking/ranking-data"; +import CountryFlag from "@/components/country-flag"; +import { normalizedRegionName } from "@ssr/common/utils/region-utils"; + +const UNKNOWN_PAGE = { + title: "ScoreSaber Reloaded - Unknown Page", + description: "The page you were looking for could not be found.", +}; + +type Props = { + params: Promise<{ + slug: string[]; + }>; +}; + +type RankingPageData = { + players: ScoreSaberPlayersPageToken | undefined; + page: number; + country: string | undefined; +}; + +const rankingCache = new NodeCache({ stdTTL: 60, checkperiod: 120 }); + +/** + * Gets the ranking data. + * + * @param params the params + * @returns the ranking data + */ +const getRankingData = async ({ params }: Props): Promise => { + const { slug } = await params; + const country = (slug && slug.length > 1 && (slug[0] as string).toUpperCase()) || undefined; // The country query + const page = (slug && parseInt(slug[country != undefined ? 1 : 0])) || 1; // The page number + + const cacheId = `${country === undefined ? "global" : country}-${page}`; + if (rankingCache.has(cacheId)) { + return rankingCache.get(cacheId) as RankingPageData; + } + + const players = + country == undefined + ? await scoresaberService.lookupPlayers(page) + : await scoresaberService.lookupPlayersByCountry(page, country); + const rankingData = { + players: players && players.players.length > 0 ? players : undefined, + page, + country, + }; + rankingCache.set(cacheId, rankingData); + return rankingData; +}; + +export async function generateMetadata(props: Props): Promise { + const { players, page, country } = await getRankingData(props); + if (players === undefined) { + return { + title: UNKNOWN_PAGE.title, + description: UNKNOWN_PAGE.description, + openGraph: { + title: UNKNOWN_PAGE.title, + description: UNKNOWN_PAGE.description, + }, + }; + } + + const title = `ScoreSaber Reloaded - Ranking Page (${page} - ${country === undefined ? "Global" : country})`; + return { + title: title, + openGraph: { + title: title, + description: ` + Page: ${page} + ${country != undefined ? `Country: ${country}` : ""} + + View the scores for the ranking page!`, + images: [ + { + // Show the profile picture of the first player + url: players.players[0].profilePicture, + }, + ], + }, + twitter: { + card: "summary", + }, + }; +} + +export default async function RankingPage(props: Props) { + const { players, page, country } = await getRankingData(props); + + return ( + +
+ {country && } +

+ You are viewing {country ? "players from " + normalizedRegionName(country.toUpperCase()) : "Global players"} +

+
+ + +
+ ); +} diff --git a/projects/website/src/components/loaders/database-loader.tsx b/projects/website/src/components/loaders/database-loader.tsx index 5a4e0fa..d2ac565 100644 --- a/projects/website/src/components/loaders/database-loader.tsx +++ b/projects/website/src/components/loaders/database-loader.tsx @@ -3,7 +3,6 @@ import { createContext, ReactNode, useEffect, useState } from "react"; import Database, { db } from "../../common/database/database"; import FullscreenLoader from "./fullscreen-loader"; -import { useToast } from "@/hooks/use-toast"; /** * The context for the database. This is used to access the database from within the app. diff --git a/projects/website/src/components/navbar/navbar.tsx b/projects/website/src/components/navbar/navbar.tsx index aa6c3ec..07b9160 100644 --- a/projects/website/src/components/navbar/navbar.tsx +++ b/projects/website/src/components/navbar/navbar.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import React from "react"; import NavbarButton from "./navbar-button"; import ProfileButton from "./profile-button"; +import { TrendingUpIcon } from "lucide-react"; type NavbarItem = { name: string; @@ -19,6 +20,12 @@ const items: NavbarItem[] = [ align: "left", icon: , }, + { + name: "Ranking", + link: "/ranking", + align: "left", + icon: , + }, { name: "Search", link: "/search", diff --git a/projects/website/src/components/player/player-header.tsx b/projects/website/src/components/player/player-header.tsx index e223d4d..51462d9 100644 --- a/projects/website/src/components/player/player-header.tsx +++ b/projects/website/src/components/player/player-header.tsx @@ -9,6 +9,7 @@ import Tooltip from "@/components/tooltip"; import { ReactElement } from "react"; import PlayerTrackedStatus from "@/components/player/player-tracked-status"; import ScoreSaberPlayer from "@ssr/common/types/player/impl/scoresaber-player"; +import Link from "next/link"; /** * Renders the change for a stat. @@ -41,10 +42,12 @@ const playerData = [ const rankChange = statisticChange?.rank ?? 0; return ( -
-

#{formatNumberWithCommas(player.rank)}

- {rankChange != 0 && renderChange(rankChange,

The change in your rank compared to yesterday

)} -
+ +
+

#{formatNumberWithCommas(player.rank)}

+ {rankChange != 0 && renderChange(rankChange,

The change in your rank compared to yesterday

)} +
+ ); }, }, @@ -58,10 +61,12 @@ const playerData = [ const rankChange = statisticChange?.countryRank ?? 0; return ( -
-

#{formatNumberWithCommas(player.countryRank)}

- {rankChange != 0 && renderChange(rankChange,

The change in your rank compared to yesterday

)} -
+ +
+

#{formatNumberWithCommas(player.countryRank)}

+ {rankChange != 0 && renderChange(rankChange,

The change in your country rank compared to yesterday

)} +
+ ); }, }, diff --git a/projects/website/src/components/ranking/player-ranking.tsx b/projects/website/src/components/ranking/player-ranking.tsx new file mode 100644 index 0000000..150adb4 --- /dev/null +++ b/projects/website/src/components/ranking/player-ranking.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { formatNumberWithCommas, formatPp } from "@/common/number-utils"; +import CountryFlag from "@/components/country-flag"; +import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; +import Link from "next/link"; +import useDatabase from "@/hooks/use-database"; +import { useLiveQuery } from "dexie-react-hooks"; +import { Avatar, AvatarImage } from "@/components/ui/avatar"; + +type PlayerRankingProps = { + player: ScoreSaberPlayerToken; + isCountry: boolean; +}; + +export function PlayerRanking({ player, isCountry }: PlayerRankingProps) { + const database = useDatabase(); + const settings = useLiveQuery(() => database.getSettings()); + + return ( + <> + + #{formatNumberWithCommas(isCountry ? player.countryRank : player.rank)}{" "} + {isCountry && "(#" + formatNumberWithCommas(player.rank) + ")"} + + + + + + + +

+ {player.name} +

+ + + {formatPp(player.pp)}pp + {formatNumberWithCommas(player.scoreStats.totalPlayCount)} + {formatNumberWithCommas(player.scoreStats.rankedPlayCount)} + {player.scoreStats.averageRankedAccuracy.toFixed(2) + "%"} + + ); +} diff --git a/projects/website/src/components/ranking/ranking-data.tsx b/projects/website/src/components/ranking/ranking-data.tsx new file mode 100644 index 0000000..fb5deff --- /dev/null +++ b/projects/website/src/components/ranking/ranking-data.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; +import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; +import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import Pagination from "@/components/input/pagination"; +import { PlayerRanking } from "@/components/ranking/player-ranking"; + +const REFRESH_INTERVAL = 1000 * 60 * 5; + +type RankingDataProps = { + /** + * The page to show when opening the leaderboard. + */ + initialPage: number; + + /** + * The country to show when opening the leaderboard. + */ + country?: string | undefined; + + /** + * The leaderboard to display. + */ + initialPageData?: ScoreSaberPlayersPageToken; +}; + +export default function RankingData({ initialPage, country, initialPageData }: RankingDataProps) { + const isMobile = useIsMobile(); + + const [currentPage, setCurrentPage] = useState(initialPage); + const [rankingData, setRankingData] = useState(initialPageData); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["rankingData", currentPage], + queryFn: async () => { + const players = + country == undefined + ? await scoresaberService.lookupPlayers(currentPage) + : await scoresaberService.lookupPlayersByCountry(currentPage, country); + return players && players.players.length > 0 ? players : undefined; + }, + staleTime: REFRESH_INTERVAL, + refetchInterval: REFRESH_INTERVAL, + refetchIntervalInBackground: false, + }); + + useEffect(() => { + if (data && (!isLoading || !isError)) { + setRankingData(data); + } + }, [data, isLoading, isError]); + + /** + * Gets the URL to the page. + */ + const getUrl = useCallback( + (page: number) => { + return `/ranking/${country != undefined ? `${country}/` : ""}${page}`; + }, + [country] + ); + + /** + * Handle updating the URL when the page number, + * sort, or search term changes. + */ + useEffect(() => { + const newUrl = getUrl(currentPage); + window.history.replaceState({ ...window.history.state, as: newUrl, url: newUrl }, "", newUrl); + }, [currentPage, getUrl]); + + if (!rankingData) { + return

Unknown page.

; + } + + const { players, metadata } = rankingData; + + return ( +
+ + + + + + + + + + + + + {players.map(player => ( + + + + ))} + +
RankProfilePerformance PointsTotal PlaysTotal Ranked PlaysAvg Ranked Accuracy
+ + {/* Pagination */} + { + return getUrl(page); + }} + onPageChange={newPage => { + setCurrentPage(newPage); + }} + /> +
+ ); +} diff --git a/projects/website/src/components/score/score-animation.tsx b/projects/website/src/components/score/score-animation.tsx index ec648a6..75d0fe5 100644 --- a/projects/website/src/components/score/score-animation.tsx +++ b/projects/website/src/components/score/score-animation.tsx @@ -6,5 +6,5 @@ import { Variants } from "framer-motion"; export const scoreAnimation: Variants = { hiddenRight: { opacity: 0, x: 50 }, hiddenLeft: { opacity: 0, x: -50 }, - visible: { opacity: 1, x: 0, transition: { staggerChildren: 0.03 } }, + visible: { opacity: 1, x: 0, transition: { staggerChildren: 0.02 } }, }; diff --git a/projects/website/src/middleware.ts b/projects/website/src/middleware.ts index 3f237ab..91cecd4 100644 --- a/projects/website/src/middleware.ts +++ b/projects/website/src/middleware.ts @@ -1,4 +1,4 @@ -import { NextResponse, type NextRequest } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { isProduction } from "@ssr/common/utils/utils"; export function middleware(request: NextRequest) {