add simple live score feed page
This commit is contained in:
parent
ee212150fd
commit
4cc5893757
@ -0,0 +1,11 @@
|
|||||||
|
export type ScoreSaberWebsocketMessageToken = {
|
||||||
|
/**
|
||||||
|
* Command name
|
||||||
|
*/
|
||||||
|
commandName: "score";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command data
|
||||||
|
*/
|
||||||
|
commandData: any;
|
||||||
|
};
|
20
projects/website/src/app/(pages)/scores/page.tsx
Normal file
20
projects/website/src/app/(pages)/scores/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import ScoreFeed from "@/components/score/score-feed/score-feed";
|
||||||
|
import Card from "@/components/card";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Score Feed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScoresPage() {
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col gap-2 w-full xl:w-[75%]">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold'">Live Score Feed</p>
|
||||||
|
<p className="text-gray-400">This is the real-time scores being set on ScoreSaber.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScoreFeed />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
@ -41,7 +41,7 @@ export async function setCookieValue(name: CookieName, value: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { set } = (await import("js-cookie")).default;
|
const { set } = (await import("js-cookie")).default;
|
||||||
return set(name, value, {
|
set(name, value, {
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import NavbarButton from "./navbar-button";
|
import NavbarButton from "./navbar-button";
|
||||||
import ProfileButton from "./profile-button";
|
import ProfileButton from "./profile-button";
|
||||||
import { TrendingUpIcon } from "lucide-react";
|
import { SwordIcon, TrendingUpIcon } from "lucide-react";
|
||||||
|
|
||||||
type NavbarItem = {
|
type NavbarItem = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -26,6 +26,12 @@ const items: NavbarItem[] = [
|
|||||||
align: "left",
|
align: "left",
|
||||||
icon: <TrendingUpIcon className="h-5 w-5" />,
|
icon: <TrendingUpIcon className="h-5 w-5" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Score Feed",
|
||||||
|
link: "/scores",
|
||||||
|
align: "left",
|
||||||
|
icon: <SwordIcon className="h-5 w-5" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Search",
|
name: "Search",
|
||||||
link: "/search",
|
link: "/search",
|
||||||
|
@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useScoreSaberWebsocket } from "@/hooks/use-scoresaber-websocket";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
||||||
|
import Score from "@/components/score/score";
|
||||||
|
import { parseDate } from "@ssr/common/utils/time-utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ScoreFeed() {
|
||||||
|
const { connected, message } = useScoreSaberWebsocket();
|
||||||
|
const [scores, setScores] = useState<ScoreSaberPlayerScoreToken[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { commandName, commandData } = message;
|
||||||
|
if (commandName !== "score") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setScores(prev => {
|
||||||
|
const newScores = [...prev, message.commandData];
|
||||||
|
if (newScores.length > 8) {
|
||||||
|
newScores.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest to oldest
|
||||||
|
return newScores.sort((a, b) => parseDate(b.score.timeSet).getTime() - parseDate(a.score.timeSet).getTime());
|
||||||
|
});
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
return <p>Not Connected to the ScoreSaber Websocket :(</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scores.length == 0) {
|
||||||
|
return <p>Waiting for scores...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col divide-y divide-border">
|
||||||
|
{scores.map(score => {
|
||||||
|
const player = score.score.leaderboardPlayerInfo;
|
||||||
|
return (
|
||||||
|
<div key={score.score.id} className="flex flex-col py-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
Set by{" "}
|
||||||
|
<Link href={`/player/${player.id}`}>
|
||||||
|
<span className="text-pp hover:brightness-75 transition-all transform-gpu">{player.name}</span>
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<Score
|
||||||
|
playerScore={score}
|
||||||
|
settings={{
|
||||||
|
noScoreButtons: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -18,20 +18,15 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
|||||||
const mappersProfile =
|
const mappersProfile =
|
||||||
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
||||||
|
|
||||||
|
const starCount = leaderboard.stars;
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<div className="relative flex justify-center h-[64px]">
|
<div className="relative flex justify-center h-[64px]">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
display={
|
display={
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>Difficulty: {diff}</p>
|
||||||
Difficulty: <span className="font-bold">{diff}</span>
|
{starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
|
||||||
</p>
|
|
||||||
{leaderboard.stars > 0 && (
|
|
||||||
<p>
|
|
||||||
Stars: <span className="font-bold">{leaderboard.stars}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -41,9 +36,9 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
|||||||
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255)
|
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{leaderboard.stars > 0 ? (
|
{starCount > 0 ? (
|
||||||
<div className="flex gap-1 items-center justify-center">
|
<div className="flex gap-1 items-center justify-center">
|
||||||
<p>{leaderboard.stars}</p>
|
<p>{starCount.toFixed(2)}</p>
|
||||||
<StarIcon className="w-[14px] h-[14px]" />
|
<StarIcon className="w-[14px] h-[14px]" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
@ -24,15 +24,26 @@ type Props = {
|
|||||||
* The score to display.
|
* The score to display.
|
||||||
*/
|
*/
|
||||||
playerScore: ScoreSaberPlayerScoreToken;
|
playerScore: ScoreSaberPlayerScoreToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score settings
|
||||||
|
*/
|
||||||
|
settings?: {
|
||||||
|
noScoreButtons: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Score({ player, playerScore }: Props) {
|
export default function Score({ player, playerScore, settings }: Props) {
|
||||||
const { score, leaderboard } = playerScore;
|
const { score, leaderboard } = playerScore;
|
||||||
const [baseScore, setBaseScore] = useState<number>(score.baseScore);
|
const [baseScore, setBaseScore] = useState<number>(score.baseScore);
|
||||||
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
||||||
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||||
|
|
||||||
const fetchBeatSaverData = useCallback(async () => {
|
const fetchBeatSaverData = useCallback(async () => {
|
||||||
|
// No need to fetch if no buttons
|
||||||
|
if (!settings?.noScoreButtons) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash);
|
const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash);
|
||||||
setBeatSaverMap(beatSaverMapData);
|
setBeatSaverMap(beatSaverMapData);
|
||||||
}, [leaderboard.songHash]);
|
}, [leaderboard.songHash]);
|
||||||
@ -48,13 +59,20 @@ export default function Score({ player, playerScore }: Props) {
|
|||||||
}, [fetchBeatSaverData]);
|
}, [fetchBeatSaverData]);
|
||||||
|
|
||||||
const accuracy = (baseScore / leaderboard.maxScore) * 100;
|
const accuracy = (baseScore / leaderboard.maxScore) * 100;
|
||||||
const pp = baseScore == score.baseScore ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
|
const pp = baseScore === score.baseScore ? score.pp : scoresaberService.getPp(leaderboard.stars, accuracy);
|
||||||
|
|
||||||
|
// Dynamic grid column classes
|
||||||
|
const gridColsClass = settings?.noScoreButtons
|
||||||
|
? "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_300px]" // Fewer columns if no buttons
|
||||||
|
: "grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]"; // Original with buttons
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-2 pt-2">
|
<div className="pb-2 pt-2">
|
||||||
{/* Score Info */}
|
{/* Score Info */}
|
||||||
<div className="grid w-full gap-2 lg:gap-0 grid-cols-[20px 1fr_1fr] lg:grid-cols-[0.5fr_4fr_1fr_300px]">
|
<div className={`grid w-full gap-2 lg:gap-0 ${gridColsClass}`}>
|
||||||
<ScoreRankInfo score={score} leaderboard={leaderboard} />
|
<ScoreRankInfo score={score} leaderboard={leaderboard} />
|
||||||
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||||
|
{settings?.noScoreButtons !== true && (
|
||||||
<ScoreButtons
|
<ScoreButtons
|
||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
beatSaverMap={beatSaverMap}
|
beatSaverMap={beatSaverMap}
|
||||||
@ -64,6 +82,7 @@ export default function Score({ player, playerScore }: Props) {
|
|||||||
setBaseScore(score.baseScore);
|
setBaseScore(score.baseScore);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<ScoreStats
|
<ScoreStats
|
||||||
score={{
|
score={{
|
||||||
...score,
|
...score,
|
||||||
|
84
projects/website/src/hooks/use-scoresaber-websocket.ts
Normal file
84
projects/website/src/hooks/use-scoresaber-websocket.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ScoreSaberWebsocketMessageToken,
|
||||||
|
} from "@ssr/common/types/token/scoresaber/websocket/scoresaber-websocket-message";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to the ScoreSaber websocket.
|
||||||
|
* Waits until the page is loaded before establishing the connection.
|
||||||
|
*/
|
||||||
|
export const useScoreSaberWebsocket = () => {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [message, setMessage] = useState<ScoreSaberWebsocketMessageToken | null>(null); // Store the incoming message
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only set isClient to true when we're on the client side
|
||||||
|
setIsClient(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If not on the client side, don't attempt to connect
|
||||||
|
if (!isClient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectWebSocket = () => {
|
||||||
|
// Create a new WebSocket instance
|
||||||
|
socketRef.current = new WebSocket("wss://scoresaber.com/ws");
|
||||||
|
|
||||||
|
socketRef.current.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
socketRef.current.onmessage = event => {
|
||||||
|
// Ignore welcome message
|
||||||
|
if (event.data === "Connected to the ScoreSaber WSS") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming messages here and store them in state
|
||||||
|
const messageData = JSON.parse(event.data);
|
||||||
|
setMessage(messageData); // Store the message in the state
|
||||||
|
};
|
||||||
|
|
||||||
|
socketRef.current.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
attemptReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
socketRef.current.onerror = () => {
|
||||||
|
socketRef.current?.close(); // Close the socket on error
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptReconnect = () => {
|
||||||
|
// Clear any existing timeouts to avoid multiple reconnection attempts
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
// Try reconnecting after 5 seconds
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
connectWebSocket();
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize WebSocket connection
|
||||||
|
connectWebSocket();
|
||||||
|
|
||||||
|
// Cleanup function when component unmounts
|
||||||
|
return () => {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isClient]); // Depend on isClient to ensure the code only runs on the client
|
||||||
|
|
||||||
|
return { connected, message }; // Return both the connection status and the last received message
|
||||||
|
};
|
Reference in New Issue
Block a user