redesign leaderboard scores
This commit is contained in:
parent
caf5f01a09
commit
16c34adc19
@ -45,10 +45,7 @@ export default class LeaderboardService {
|
|||||||
* @param id the players id
|
* @param id the players id
|
||||||
* @returns the scores
|
* @returns the scores
|
||||||
*/
|
*/
|
||||||
public static async getLeaderboard(
|
public static async getLeaderboard<L>(leaderboardName: Leaderboards, id: string): Promise<LeaderboardResponse<L>> {
|
||||||
leaderboardName: Leaderboards,
|
|
||||||
id: string
|
|
||||||
): Promise<LeaderboardResponse<Leaderboard>> {
|
|
||||||
let leaderboard: Leaderboard | undefined;
|
let leaderboard: Leaderboard | undefined;
|
||||||
let beatSaverMap: BeatSaverMap | undefined;
|
let beatSaverMap: BeatSaverMap | undefined;
|
||||||
|
|
||||||
@ -71,7 +68,7 @@ export default class LeaderboardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leaderboard: leaderboard,
|
leaderboard: leaderboard as L,
|
||||||
beatsaver: beatSaverMap,
|
beatsaver: beatSaverMap,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,9 @@ import { isProduction } from "@ssr/common/utils/utils";
|
|||||||
import { Metadata } from "@ssr/common/types/metadata";
|
import { Metadata } from "@ssr/common/types/metadata";
|
||||||
import { NotFoundError } from "elysia";
|
import { NotFoundError } from "elysia";
|
||||||
import BeatSaverService from "./beatsaver.service";
|
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 { getScoreSaberScoreFromToken } from "@ssr/common/score/impl/scoresaber-score";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import { ScoreSort } from "@ssr/common/score/score-sort";
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
@ -154,7 +156,7 @@ export class ScoreService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const token of leaderboardScores.playerScores) {
|
for (const token of leaderboardScores.playerScores) {
|
||||||
const score = getScoreSaberScoreFromToken(token.score);
|
const score = getScoreSaberScoreFromToken(token.score, token.leaderboard);
|
||||||
if (score == undefined) {
|
if (score == undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -206,7 +208,10 @@ export class ScoreService {
|
|||||||
|
|
||||||
switch (leaderboardName) {
|
switch (leaderboardName) {
|
||||||
case "scoresaber": {
|
case "scoresaber": {
|
||||||
const leaderboardResponse = await LeaderboardService.getLeaderboard(leaderboardName, id);
|
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
|
||||||
|
leaderboardName,
|
||||||
|
id
|
||||||
|
);
|
||||||
if (leaderboardResponse == undefined) {
|
if (leaderboardResponse == undefined) {
|
||||||
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
||||||
}
|
}
|
||||||
@ -219,7 +224,7 @@ export class ScoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const token of leaderboardScores.scores) {
|
for (const token of leaderboardScores.scores) {
|
||||||
const score = getScoreSaberScoreFromToken(token);
|
const score = getScoreSaberScoreFromToken(token, leaderboardResponse.leaderboard);
|
||||||
if (score == undefined) {
|
if (score == undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { Modifier } from "../modifier";
|
|||||||
import ScoreSaberScoreToken from "../../types/token/scoresaber/score-saber-score-token";
|
import ScoreSaberScoreToken from "../../types/token/scoresaber/score-saber-score-token";
|
||||||
import ScoreSaberLeaderboardPlayerInfoToken from "../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
|
import ScoreSaberLeaderboardPlayerInfoToken from "../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
|
||||||
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
|
import ScoreSaberLeaderboard from "../../leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
|
||||||
export default interface ScoreSaberScore extends Score {
|
export default interface ScoreSaberScore extends Score {
|
||||||
/**
|
/**
|
||||||
@ -41,7 +42,7 @@ export default interface ScoreSaberScore extends Score {
|
|||||||
*/
|
*/
|
||||||
export function getScoreSaberScoreFromToken(
|
export function getScoreSaberScoreFromToken(
|
||||||
token: ScoreSaberScoreToken,
|
token: ScoreSaberScoreToken,
|
||||||
leaderboard?: ScoreSaberLeaderboardToken
|
leaderboard?: ScoreSaberLeaderboardToken | ScoreSaberLeaderboard
|
||||||
): ScoreSaberScore {
|
): ScoreSaberScore {
|
||||||
const modifiers: Modifier[] =
|
const modifiers: Modifier[] =
|
||||||
token.modifiers == undefined || token.modifiers === ""
|
token.modifiers == undefined || token.modifiers === ""
|
||||||
|
@ -123,5 +123,11 @@ export default async function LeaderboardPage(props: Props) {
|
|||||||
if (response == undefined) {
|
if (response == undefined) {
|
||||||
return redirect("/");
|
return redirect("/");
|
||||||
}
|
}
|
||||||
return <LeaderboardData initialLeaderboard={response.leaderboardResponse} initialScores={response.scores} />;
|
return (
|
||||||
|
<LeaderboardData
|
||||||
|
initialLeaderboard={response.leaderboardResponse}
|
||||||
|
initialScores={response.scores}
|
||||||
|
initialPage={response.page}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -71,6 +71,17 @@ export default class Database extends Dexie {
|
|||||||
return this.settings.update(SETTINGS_ID, settings);
|
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
|
* Adds a friend
|
||||||
*
|
*
|
||||||
|
@ -6,7 +6,7 @@ import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
|||||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
|
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
|
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
|
||||||
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||||
|
|
||||||
@ -22,12 +22,17 @@ type LeaderboardDataProps = {
|
|||||||
* The initial score data.
|
* The initial score data.
|
||||||
*/
|
*/
|
||||||
initialScores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
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 [currentLeaderboardId, setCurrentLeaderboardId] = useState(initialLeaderboard.leaderboard.id);
|
||||||
|
const [currentLeaderboard, setCurrentLeaderboard] = useState(initialLeaderboard);
|
||||||
|
|
||||||
let leaderboard = initialLeaderboard;
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ["leaderboard", currentLeaderboardId],
|
queryKey: ["leaderboard", currentLeaderboardId],
|
||||||
queryFn: async (): Promise<LeaderboardResponse<ScoreSaberLeaderboard> | undefined> => {
|
queryFn: async (): Promise<LeaderboardResponse<ScoreSaberLeaderboard> | undefined> => {
|
||||||
@ -37,20 +42,23 @@ export function LeaderboardData({ initialLeaderboard, initialScores }: Leaderboa
|
|||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data && (!isLoading || !isError)) {
|
useEffect(() => {
|
||||||
leaderboard = data;
|
if (data) {
|
||||||
|
setCurrentLeaderboard(data);
|
||||||
}
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col-reverse xl:flex-row w-full gap-2">
|
<main className="flex flex-col-reverse xl:flex-row w-full gap-2">
|
||||||
<LeaderboardScores
|
<LeaderboardScores
|
||||||
leaderboard={leaderboard.leaderboard}
|
leaderboard={currentLeaderboard.leaderboard}
|
||||||
initialScores={initialScores}
|
initialScores={initialScores}
|
||||||
|
initialPage={initialPage}
|
||||||
leaderboardChanged={newId => setCurrentLeaderboardId(newId)}
|
leaderboardChanged={newId => setCurrentLeaderboardId(newId)}
|
||||||
showDifficulties
|
showDifficulties
|
||||||
isLeaderboardPage
|
isLeaderboardPage
|
||||||
/>
|
/>
|
||||||
<LeaderboardInfo leaderboard={leaderboard.leaderboard} beatSaverMap={leaderboard.beatsaver} />
|
<LeaderboardInfo leaderboard={currentLeaderboard.leaderboard} beatSaverMap={currentLeaderboard.beatsaver} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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 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 = {
|
type Props = {
|
||||||
/**
|
|
||||||
* The player who set the score.
|
|
||||||
*/
|
|
||||||
player?: ScoreSaberPlayer;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The score to display.
|
* The score to display.
|
||||||
*/
|
*/
|
||||||
score: ScoreSaberScore;
|
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 (
|
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]">
|
{/* Score Rank */}
|
||||||
<ScoreRankInfo score={score} leaderboard={leaderboard} />
|
<td className="px-4 py-2 whitespace-nowrap">#{score.rank}</td>
|
||||||
<LeaderboardPlayer player={player} score={score} />
|
|
||||||
<LeaderboardScoreStats score={score} leaderboard={leaderboard} />
|
{/* Player */}
|
||||||
</div>
|
<td className="px-4 py-2 flex gap-2 whitespace-nowrap">
|
||||||
</div>
|
<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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,56 +10,26 @@ import LeaderboardScore from "./leaderboard-score";
|
|||||||
import { scoreAnimation } from "@/components/score/score-animation";
|
import { scoreAnimation } from "@/components/score/score-animation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { clsx } from "clsx";
|
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 { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
||||||
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
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 LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||||
|
import useDatabase from "@/hooks/use-database";
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
|
||||||
type LeaderboardScoresProps = {
|
type LeaderboardScoresProps = {
|
||||||
/**
|
|
||||||
* The page to show when opening the leaderboard.
|
|
||||||
*/
|
|
||||||
initialPage?: number;
|
initialPage?: number;
|
||||||
|
|
||||||
/**
|
|
||||||
* The initial scores to show.
|
|
||||||
*/
|
|
||||||
initialScores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
initialScores?: LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||||
|
|
||||||
/**
|
|
||||||
* The leaderboard to display.
|
|
||||||
*/
|
|
||||||
leaderboard: ScoreSaberLeaderboard;
|
leaderboard: ScoreSaberLeaderboard;
|
||||||
|
|
||||||
/**
|
|
||||||
* The player who set the score.
|
|
||||||
*/
|
|
||||||
player?: ScoreSaberPlayer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to show the difficulties.
|
|
||||||
*/
|
|
||||||
showDifficulties?: boolean;
|
showDifficulties?: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this is the full leaderboard page.
|
|
||||||
*/
|
|
||||||
isLeaderboardPage?: boolean;
|
isLeaderboardPage?: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the leaderboard changes.
|
|
||||||
*
|
|
||||||
* @param id the new leaderboard id
|
|
||||||
*/
|
|
||||||
leaderboardChanged?: (id: number) => void;
|
leaderboardChanged?: (id: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LeaderboardScores({
|
export default function LeaderboardScores({
|
||||||
initialPage,
|
initialPage,
|
||||||
initialScores,
|
initialScores,
|
||||||
player,
|
|
||||||
leaderboard,
|
leaderboard,
|
||||||
showDifficulties,
|
showDifficulties,
|
||||||
isLeaderboardPage,
|
isLeaderboardPage,
|
||||||
@ -68,6 +38,9 @@ export default function LeaderboardScores({
|
|||||||
if (!initialPage) {
|
if (!initialPage) {
|
||||||
initialPage = 1;
|
initialPage = 1;
|
||||||
}
|
}
|
||||||
|
const database = useDatabase();
|
||||||
|
const claimedPlayer = useLiveQuery(() => database.getClaimedPlayer());
|
||||||
|
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const controls = useAnimation();
|
const controls = useAnimation();
|
||||||
|
|
||||||
@ -78,7 +51,7 @@ export default function LeaderboardScores({
|
|||||||
LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined
|
LeaderboardScoresResponse<ScoreSaberScore, ScoreSaberLeaderboard> | undefined
|
||||||
>(initialScores);
|
>(initialScores);
|
||||||
const topOfScoresRef = useRef<HTMLDivElement>(null);
|
const topOfScoresRef = useRef<HTMLDivElement>(null);
|
||||||
const [shouldFetch, setShouldFetch] = useState(true);
|
const [shouldFetch, setShouldFetch] = useState(false);
|
||||||
|
|
||||||
const { data, isError, isLoading } = useQuery({
|
const { data, isError, isLoading } = useQuery({
|
||||||
queryKey: ["leaderboardScores", selectedLeaderboardId, currentPage],
|
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 () => {
|
const handleScoreAnimation = useCallback(async () => {
|
||||||
await controls.start(previousPage >= currentPage ? "hiddenRight" : "hiddenLeft");
|
await controls.start(previousPage >= currentPage ? "hiddenRight" : "hiddenLeft");
|
||||||
@ -189,20 +162,28 @@ export default function LeaderboardScores({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div className="overflow-x-auto">
|
||||||
initial="hidden"
|
<table className="table w-full table-auto border-spacing-2 border-none text-left">
|
||||||
animate={controls}
|
<thead>
|
||||||
variants={scoreAnimation}
|
<tr>
|
||||||
className="grid min-w-full grid-cols-1 divide-y divide-border"
|
<th className="px-4 py-2">Rank</th>
|
||||||
>
|
<th className="px-4 py-2">Player</th>
|
||||||
{currentScores.scores.map((playerScore, index) => {
|
<th className="px-4 py-2 text-center">Time Set</th>
|
||||||
return (
|
<th className="px-4 py-2 text-center">Score</th>
|
||||||
<motion.div key={index} variants={scoreAnimation}>
|
<th className="px-4 py-2 text-center">Accuracy</th>
|
||||||
<LeaderboardScore key={index} player={player} score={playerScore} leaderboard={leaderboard} />
|
<th className="px-4 py-2 text-center">Misses</th>
|
||||||
</motion.div>
|
<th className="px-4 py-2 text-center">PP</th>
|
||||||
);
|
</tr>
|
||||||
})}
|
</thead>
|
||||||
</motion.div>
|
<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
|
<Pagination
|
||||||
mobilePagination={width < 768}
|
mobilePagination={width < 768}
|
||||||
|
@ -222,12 +222,7 @@ export default function PlayerScores({ initialScoreData, initialSearch, player,
|
|||||||
>
|
>
|
||||||
{scores.scores.map((score, index) => (
|
{scores.scores.map((score, index) => (
|
||||||
<motion.div key={score.score.id} variants={scoreAnimation}>
|
<motion.div key={score.score.id} variants={scoreAnimation}>
|
||||||
<Score
|
<Score score={score.score} leaderboard={score.leaderboard} beatSaverMap={score.beatSaver} />
|
||||||
player={player}
|
|
||||||
score={score.score}
|
|
||||||
leaderboard={score.leaderboard}
|
|
||||||
beatSaverMap={score.beatSaver}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
|
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 ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
import Link from "next/link";
|
|
||||||
import useDatabase from "@/hooks/use-database";
|
import useDatabase from "@/hooks/use-database";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { Avatar, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { TablePlayer } from "@/components/table-player";
|
||||||
|
|
||||||
type PlayerRankingProps = {
|
type PlayerRankingProps = {
|
||||||
player: ScoreSaberPlayerToken;
|
player: ScoreSaberPlayerToken;
|
||||||
@ -16,7 +14,7 @@ type PlayerRankingProps = {
|
|||||||
|
|
||||||
export function PlayerRanking({ player, isCountry }: PlayerRankingProps) {
|
export function PlayerRanking({ player, isCountry }: PlayerRankingProps) {
|
||||||
const database = useDatabase();
|
const database = useDatabase();
|
||||||
const settings = useLiveQuery(() => database.getSettings());
|
const claimedPlayer = useLiveQuery(() => database.getClaimedPlayer());
|
||||||
|
|
||||||
const history = player.histories.split(",").map(Number);
|
const history = player.histories.split(",").map(Number);
|
||||||
const weeklyRankChange = history[history?.length - 6] - player.rank;
|
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>
|
<span className="text-sm">{isCountry && "(#" + formatNumberWithCommas(player.rank) + ")"}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="flex items-center gap-2 px-4 py-2">
|
<td className="flex items-center gap-2 px-4 py-2">
|
||||||
<Avatar className="w-[24px] h-[24px] pointer-events-none">
|
<TablePlayer player={player} claimedPlayer={claimedPlayer} />
|
||||||
<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>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-pp text-center">{formatPp(player.pp)}pp</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>
|
<td className="px-4 py-2 text-center">{formatNumberWithCommas(player.scoreStats.totalPlayCount)}</td>
|
||||||
|
@ -7,7 +7,6 @@ import ScoreSongInfo from "./score-info";
|
|||||||
import ScoreRankInfo from "./score-rank-info";
|
import ScoreRankInfo from "./score-rank-info";
|
||||||
import ScoreStats from "./score-stats";
|
import ScoreStats from "./score-stats";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
|
||||||
import { getPageFromRank } from "@ssr/common/utils/utils";
|
import { getPageFromRank } from "@ssr/common/utils/utils";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
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";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
|
||||||
* The player who set the score.
|
|
||||||
*/
|
|
||||||
player?: ScoreSaberPlayer;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The leaderboard.
|
* 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 isMobile = useIsMobile();
|
||||||
const [baseScore, setBaseScore] = useState<number>(score.score);
|
const [baseScore, setBaseScore] = useState<number>(score.score);
|
||||||
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||||
@ -109,7 +103,7 @@ export default function Score({ player, leaderboard, beatSaverMap, score, settin
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="w-full mt-2"
|
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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
36
projects/website/src/components/table-player.tsx
Normal file
36
projects/website/src/components/table-player.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user