redesign leaderboard scores
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m9s

This commit is contained in:
Lee 2024-10-19 14:11:43 +01:00
parent caf5f01a09
commit 16c34adc19
14 changed files with 152 additions and 238 deletions

View File

@ -45,10 +45,7 @@ export default class LeaderboardService {
* @param id the players id
* @returns the scores
*/
public static async getLeaderboard(
leaderboardName: Leaderboards,
id: string
): Promise<LeaderboardResponse<Leaderboard>> {
public static async getLeaderboard<L>(leaderboardName: Leaderboards, id: string): Promise<LeaderboardResponse<L>> {
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
@ -71,7 +68,7 @@ export default class LeaderboardService {
}
return {
leaderboard: leaderboard,
leaderboard: leaderboard as L,
beatsaver: beatSaverMap,
};
}

View File

@ -4,7 +4,9 @@ import { isProduction } from "@ssr/common/utils/utils";
import { Metadata } from "@ssr/common/types/metadata";
import { NotFoundError } from "elysia";
import BeatSaverService from "./beatsaver.service";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import ScoreSaberLeaderboard, {
getScoreSaberLeaderboardFromToken,
} from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { getScoreSaberScoreFromToken } from "@ssr/common/score/impl/scoresaber-score";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { ScoreSort } from "@ssr/common/score/score-sort";
@ -154,7 +156,7 @@ export class ScoreService {
);
for (const token of leaderboardScores.playerScores) {
const score = getScoreSaberScoreFromToken(token.score);
const score = getScoreSaberScoreFromToken(token.score, token.leaderboard);
if (score == undefined) {
continue;
}
@ -206,7 +208,10 @@ export class ScoreService {
switch (leaderboardName) {
case "scoresaber": {
const leaderboardResponse = await LeaderboardService.getLeaderboard(leaderboardName, id);
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
leaderboardName,
id
);
if (leaderboardResponse == undefined) {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
@ -219,7 +224,7 @@ export class ScoreService {
}
for (const token of leaderboardScores.scores) {
const score = getScoreSaberScoreFromToken(token);
const score = getScoreSaberScoreFromToken(token, leaderboardResponse.leaderboard);
if (score == undefined) {
continue;
}

View File

@ -3,6 +3,7 @@ import { Modifier } from "../modifier";
import ScoreSaberScoreToken from "../../types/token/scoresaber/score-saber-score-token";
import ScoreSaberLeaderboardPlayerInfoToken from "../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberLeaderboard from "../../leaderboard/impl/scoresaber-leaderboard";
export default interface ScoreSaberScore extends Score {
/**
@ -41,7 +42,7 @@ export default interface ScoreSaberScore extends Score {
*/
export function getScoreSaberScoreFromToken(
token: ScoreSaberScoreToken,
leaderboard?: ScoreSaberLeaderboardToken
leaderboard?: ScoreSaberLeaderboardToken | ScoreSaberLeaderboard
): ScoreSaberScore {
const modifiers: Modifier[] =
token.modifiers == undefined || token.modifiers === ""

View File

@ -123,5 +123,11 @@ export default async function LeaderboardPage(props: Props) {
if (response == undefined) {
return redirect("/");
}
return <LeaderboardData initialLeaderboard={response.leaderboardResponse} initialScores={response.scores} />;
return (
<LeaderboardData
initialLeaderboard={response.leaderboardResponse}
initialScores={response.scores}
initialPage={response.page}
/>
);
}

View File

@ -71,6 +71,17 @@ export default class Database extends Dexie {
return this.settings.update(SETTINGS_ID, settings);
}
/**
* Gets the claimed player's scoresaber token
*/
async getClaimedPlayer(): Promise<ScoreSaberPlayerToken | undefined> {
const settings = await this.getSettings();
if (settings == undefined || settings.playerId == undefined) {
return;
}
return scoresaberService.lookupPlayer(settings.playerId, true);
}
/**
* Adds a friend
*

View File

@ -6,7 +6,7 @@ import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
@ -22,12 +22,17 @@ type LeaderboardDataProps = {
* The initial score data.
*/
initialScores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
/**
* The initial page.
*/
initialPage?: number;
};
export function LeaderboardData({ initialLeaderboard, initialScores }: LeaderboardDataProps) {
export function LeaderboardData({ initialLeaderboard, initialScores, initialPage }: LeaderboardDataProps) {
const [currentLeaderboardId, setCurrentLeaderboardId] = useState(initialLeaderboard.leaderboard.id);
const [currentLeaderboard, setCurrentLeaderboard] = useState(initialLeaderboard);
let leaderboard = initialLeaderboard;
const { data, isLoading, isError } = useQuery({
queryKey: ["leaderboard", currentLeaderboardId],
queryFn: async (): Promise<LeaderboardResponse<ScoreSaberLeaderboard> | undefined> => {
@ -37,20 +42,23 @@ export function LeaderboardData({ initialLeaderboard, initialScores }: Leaderboa
refetchIntervalInBackground: false,
});
if (data && (!isLoading || !isError)) {
leaderboard = data;
}
useEffect(() => {
if (data) {
setCurrentLeaderboard(data);
}
}, [data]);
return (
<main className="flex flex-col-reverse xl:flex-row w-full gap-2">
<LeaderboardScores
leaderboard={leaderboard.leaderboard}
leaderboard={currentLeaderboard.leaderboard}
initialScores={initialScores}
initialPage={initialPage}
leaderboardChanged={newId => setCurrentLeaderboardId(newId)}
showDifficulties
isLeaderboardPage
/>
<LeaderboardInfo leaderboard={leaderboard.leaderboard} beatSaverMap={leaderboard.beatsaver} />
<LeaderboardInfo leaderboard={currentLeaderboard.leaderboard} beatSaverMap={currentLeaderboard.beatsaver} />
</main>
);
}

View File

@ -1,42 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
type Props = {
/**
* The player who set the score.
*/
player?: ScoreSaberPlayer;
/**
* The score to display.
*/
score: ScoreSaberScore;
};
export default function LeaderboardPlayer({ player, score }: Props) {
const scorePlayer = score.playerInfo;
const isPlayerWhoSetScore = player && scorePlayer.id === player.id;
return (
<div className="flex gap-2">
<Image
unoptimized
src={`https://img.fascinated.cc/upload/w_48,h_48/${scorePlayer.profilePicture}`}
width={48}
height={48}
alt="Song Artwork"
className="rounded-md min-w-[48px]"
priority
/>
<Link
href={`/player/${scorePlayer.id}`}
target="_blank"
className="h-fit hover:brightness-75 transition-all transform-gpu"
>
<p className={`${isPlayerWhoSetScore && "text-pp"}`}>{scorePlayer.name}</p>
</Link>
</div>
);
}

View File

@ -1,74 +0,0 @@
import { getScoreBadgeFromAccuracy } from "@/common/song-utils";
import Tooltip from "@/components/tooltip";
import { ScoreBadge, ScoreBadges } from "@/components/score/score-badge";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import FullComboBadge from "@/components/score/badges/full-combo";
const badges: ScoreBadge[] = [
{
name: "PP",
color: () => {
return "bg-pp";
},
create: (score: ScoreSaberScore) => {
const pp = score.pp;
if (pp === 0) {
return undefined;
}
return `${score.pp.toFixed(2)}pp`;
},
},
{
name: "Accuracy",
color: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
const acc = (score.score / leaderboard.maxScore) * 100;
return getScoreBadgeFromAccuracy(acc).color;
},
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
const acc = (score.score / leaderboard.maxScore) * 100;
const scoreBadge = getScoreBadgeFromAccuracy(acc);
let accDetails = `Accuracy ${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
if (scoreBadge.max == null) {
accDetails += ` (> ${scoreBadge.min}%)`;
} else if (scoreBadge.min == null) {
accDetails += ` (< ${scoreBadge.max}%)`;
} else {
accDetails += ` (${scoreBadge.min}% - ${scoreBadge.max}%)`;
}
return (
<>
<Tooltip
display={
<div>
<p>{accDetails}</p>
</div>
}
>
<p className="cursor-default">{acc.toFixed(2)}%</p>
</Tooltip>
</>
);
},
},
{
name: "Full Combo",
create: (score: ScoreSaberScore) => {
return <FullComboBadge score={score} />;
},
},
];
type Props = {
score: ScoreSaberScore;
leaderboard: ScoreSaberLeaderboard;
};
export default function LeaderboardScoreStats({ score, leaderboard }: Props) {
return (
<div className={`grid grid-cols-3 grid-rows-1 gap-1 ml-0 lg:ml-2`}>
<ScoreBadges badges={badges} score={score} leaderboard={leaderboard} />
</div>
);
}

View File

@ -1,35 +1,48 @@
import LeaderboardPlayer from "./leaderboard-player";
import LeaderboardScoreStats from "./leaderboard-score-stats";
import ScoreRankInfo from "@/components/score/score-rank-info";
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
import { timeAgo } from "@ssr/common/utils/time-utils";
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
import { TablePlayer } from "@/components/table-player";
type Props = {
/**
* The player who set the score.
*/
player?: ScoreSaberPlayer;
/**
* The score to display.
*/
score: ScoreSaberScore;
/**
* The leaderboard to display.
* The claimed player.
*/
leaderboard: ScoreSaberLeaderboard;
claimedPlayer?: ScoreSaberPlayerToken;
};
export default function LeaderboardScore({ player, score, leaderboard }: Props) {
export default function LeaderboardScore({ score, claimedPlayer }: Props) {
const scorePlayer = score.playerInfo;
return (
<div className="py-1.5">
<div className="grid items-center w-full gap-2 grid-cols-[20px 1fr_1fr] lg:grid-cols-[130px_4fr_300px]">
<ScoreRankInfo score={score} leaderboard={leaderboard} />
<LeaderboardPlayer player={player} score={score} />
<LeaderboardScoreStats score={score} leaderboard={leaderboard} />
</div>
</div>
<>
{/* Score Rank */}
<td className="px-4 py-2 whitespace-nowrap">#{score.rank}</td>
{/* Player */}
<td className="px-4 py-2 flex gap-2 whitespace-nowrap">
<TablePlayer player={scorePlayer} claimedPlayer={claimedPlayer} />
</td>
{/* Time Set */}
<td className="px-4 py-2 text-center whitespace-nowrap">{timeAgo(score.timestamp)}</td>
{/* Score */}
<td className="px-4 py-2 text-center whitespace-nowrap">{formatNumberWithCommas(score.score)}</td>
{/* Score Accuracy */}
<td className="px-4 py-2 text-center whitespace-nowrap">{score.accuracy.toFixed(2)}%</td>
{/* Score Misses */}
<td className="px-4 py-2 text-center whitespace-nowrap">{score.misses > 0 ? `${score.misses}x` : "FC"}</td>
{/* Score PP */}
<td className="px-4 py-2 text-center text-pp whitespace-nowrap">{formatPp(score.pp)}pp</td>
</>
);
}

View File

@ -10,56 +10,26 @@ import LeaderboardScore from "./leaderboard-score";
import { scoreAnimation } from "@/components/score/score-animation";
import { Button } from "@/components/ui/button";
import { clsx } from "clsx";
import { getDifficulty, getDifficultyFromRawDifficulty } from "@/common/song-utils";
import { getDifficultyFromRawDifficulty } from "@/common/song-utils";
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import useDatabase from "@/hooks/use-database";
import { useLiveQuery } from "dexie-react-hooks";
type LeaderboardScoresProps = {
/**
* The page to show when opening the leaderboard.
*/
initialPage?: number;
/**
* The initial scores to show.
*/
initialScores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
/**
* The leaderboard to display.
*/
leaderboard: ScoreSaberLeaderboard;
/**
* The player who set the score.
*/
player?: ScoreSaberPlayer;
/**
* Whether to show the difficulties.
*/
showDifficulties?: boolean;
/**
* Whether this is the full leaderboard page.
*/
isLeaderboardPage?: boolean;
/**
* Called when the leaderboard changes.
*
* @param id the new leaderboard id
*/
leaderboardChanged?: (id: number) => void;
};
export default function LeaderboardScores({
initialPage,
initialScores,
player,
leaderboard,
showDifficulties,
isLeaderboardPage,
@ -68,6 +38,9 @@ export default function LeaderboardScores({
if (!initialPage) {
initialPage = 1;
}
const database = useDatabase();
const claimedPlayer = useLiveQuery(() => database.getClaimedPlayer());
const { width } = useWindowDimensions();
const controls = useAnimation();
@ -78,7 +51,7 @@ export default function LeaderboardScores({
LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined
>(initialScores);
const topOfScoresRef = useRef<HTMLDivElement>(null);
const [shouldFetch, setShouldFetch] = useState(true);
const [shouldFetch, setShouldFetch] = useState(false);
const { data, isError, isLoading } = useQuery({
queryKey: ["leaderboardScores", selectedLeaderboardId, currentPage],
@ -93,7 +66,7 @@ export default function LeaderboardScores({
});
/**
* Starts the animation for the scores.
* Starts the animation for the scores, but only after the initial load.
*/
const handleScoreAnimation = useCallback(async () => {
await controls.start(previousPage >= currentPage ? "hiddenRight" : "hiddenLeft");
@ -189,20 +162,28 @@ export default function LeaderboardScores({
})}
</div>
<motion.div
initial="hidden"
animate={controls}
variants={scoreAnimation}
className="grid min-w-full grid-cols-1 divide-y divide-border"
>
{currentScores.scores.map((playerScore, index) => {
return (
<motion.div key={index} variants={scoreAnimation}>
<LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} />
</motion.div>
);
})}
</motion.div>
<div className="overflow-x-auto">
<table className="table w-full table-auto border-spacing-2 border-none text-left">
<thead>
<tr>
<th className="px-4 py-2">Rank</th>
<th className="px-4 py-2">Player</th>
<th className="px-4 py-2 text-center">Time Set</th>
<th className="px-4 py-2 text-center">Score</th>
<th className="px-4 py-2 text-center">Accuracy</th>
<th className="px-4 py-2 text-center">Misses</th>
<th className="px-4 py-2 text-center">PP</th>
</tr>
</thead>
<motion.tbody initial="hidden" animate={controls} className="border-none" variants={scoreAnimation}>
{currentScores.scores.map((playerScore, index) => (
<motion.tr key={index} className="border-b border-border" variants={scoreAnimation}>
<LeaderboardScore score={playerScore} claimedPlayer={claimedPlayer} />
</motion.tr>
))}
</motion.tbody>
</table>
</div>
<Pagination
mobilePagination={width < 768}

View File

@ -222,12 +222,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
>
{scores.scores.map((score, index) => (
<motion.div key={score.score.id} variants={scoreAnimation}>
<Score
player={player}
score={score.score}
leaderboard={score.leaderboard}
beatSaverMap={score.beatSaver}
/>
<Score score={score.score} leaderboard={score.leaderboard} beatSaverMap={score.beatSaver} />
</motion.div>
))}
</motion.div>

View File

@ -1,13 +1,11 @@
"use client";
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/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";
import { clsx } from "clsx";
import { TablePlayer } from "@/components/table-player";
type PlayerRankingProps = {
player: ScoreSaberPlayerToken;
@ -16,7 +14,7 @@ type PlayerRankingProps = {
export function PlayerRanking({ player, isCountry }: PlayerRankingProps) {
const database = useDatabase();
const settings = useLiveQuery(() => database.getSettings());
const claimedPlayer = useLiveQuery(() => database.getClaimedPlayer());
const history = player.histories.split(",").map(Number);
const weeklyRankChange = history[history?.length - 6] - player.rank;
@ -28,22 +26,7 @@ export function PlayerRanking({ player, isCountry }: PlayerRankingProps) {
<span className="text-sm">{isCountry && "(#" + formatNumberWithCommas(player.rank) + ")"}</span>
</td>
<td className="flex items-center gap-2 px-4 py-2">
<Avatar className="w-[24px] h-[24px] pointer-events-none">
<AvatarImage
alt="Profile Picture"
src={`https://img.fascinated.cc/upload/w_128,h_128/${player.profilePicture}`}
/>
</Avatar>
<CountryFlag code={player.country} size={12} />
<Link className="transform-gpu transition-all hover:text-blue-500" href={`/player/${player.id}`}>
<p
className={
player.id == settings?.playerId ? "transform-gpu text-pp transition-all hover:brightness-75" : ""
}
>
{player.name}
</p>
</Link>
<TablePlayer player={player} claimedPlayer={claimedPlayer} />
</td>
<td className="px-4 py-2 text-pp text-center">{formatPp(player.pp)}pp</td>
<td className="px-4 py-2 text-center">{formatNumberWithCommas(player.scoreStats.totalPlayCount)}</td>

View File

@ -7,7 +7,6 @@ import ScoreSongInfo from "./score-info";
import ScoreRankInfo from "./score-rank-info";
import ScoreStats from "./score-stats";
import { motion } from "framer-motion";
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { getPageFromRank } from "@ssr/common/utils/utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
@ -16,11 +15,6 @@ import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { useIsMobile } from "@/hooks/use-is-mobile";
type Props = {
/**
* The player who set the score.
*/
player?: ScoreSaberPlayer;
/**
* The leaderboard.
*/
@ -44,7 +38,7 @@ type Props = {
};
};
export default function Score({ player, leaderboard, beatSaverMap, score, settings }: Props) {
export default function Score({ leaderboard, beatSaverMap, score, settings }: Props) {
const isMobile = useIsMobile();
const [baseScore, setBaseScore] = useState<number>(score.score);
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
@ -109,7 +103,7 @@ export default function Score({ player, leaderboard, beatSaverMap, score, settin
animate={{ opacity: 1, y: 0 }}
className="w-full mt-2"
>
<LeaderboardScores initialPage={getPageFromRank(score.rank, 12)} player={player} leaderboard={leaderboard} />
<LeaderboardScores initialPage={getPageFromRank(score.rank, 12)} leaderboard={leaderboard} />
</motion.div>
)}
</div>

View File

@ -0,0 +1,36 @@
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import CountryFlag from "@/components/country-flag";
import Link from "next/link";
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
import ScoreSaberLeaderboardPlayerInfoToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-player-info-token";
type TablePlayerProps = {
/**
* The player to display.
*/
player: ScoreSaberPlayerToken | ScoreSaberLeaderboardPlayerInfoToken;
/**
* The claimed player.
*/
claimedPlayer?: ScoreSaberPlayerToken;
};
export function TablePlayer({ player, claimedPlayer }: TablePlayerProps) {
return (
<>
<Avatar className="w-[24px] h-[24px] pointer-events-none">
<AvatarImage
alt="Profile Picture"
src={`https://img.fascinated.cc/upload/w_128,h_128/${player.profilePicture}`}
/>
</Avatar>
<CountryFlag code={player.country} size={12} />
<Link className="transform-gpu transition-all hover:text-blue-500" href={`/player/${player.id}`}>
<p className={player.id == claimedPlayer?.id ? "transform-gpu text-pp transition-all hover:brightness-75" : ""}>
{player.name}
</p>
</Link>
</>
);
}