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;
|
||||
return set(name, value, {
|
||||
set(name, value, {
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
@ -4,7 +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";
|
||||
import { SwordIcon, TrendingUpIcon } from "lucide-react";
|
||||
|
||||
type NavbarItem = {
|
||||
name: string;
|
||||
@ -26,6 +26,12 @@ const items: NavbarItem[] = [
|
||||
align: "left",
|
||||
icon: <TrendingUpIcon className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "Score Feed",
|
||||
link: "/scores",
|
||||
align: "left",
|
||||
icon: <SwordIcon className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: "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 =
|
||||
beatSaverMap != undefined ? `https://beatsaver.com/profile/${beatSaverMap?.fullData.uploader.id}` : undefined;
|
||||
|
||||
const starCount = leaderboard.stars;
|
||||
return (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="relative flex justify-center h-[64px]">
|
||||
<Tooltip
|
||||
display={
|
||||
<>
|
||||
<p>
|
||||
Difficulty: <span className="font-bold">{diff}</span>
|
||||
</p>
|
||||
{leaderboard.stars > 0 && (
|
||||
<p>
|
||||
Stars: <span className="font-bold">{leaderboard.stars}</span>
|
||||
</p>
|
||||
)}
|
||||
<p>Difficulty: {diff}</p>
|
||||
{starCount > 0 && <p>Stars: {starCount.toFixed(2)}</p>}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@ -41,9 +36,9 @@ export default function ScoreSongInfo({ leaderboard, beatSaverMap }: Props) {
|
||||
backgroundColor: songDifficultyToColor(diff) + "f0", // Transparency value (in hex 0-255)
|
||||
}}
|
||||
>
|
||||
{leaderboard.stars > 0 ? (
|
||||
{starCount > 0 ? (
|
||||
<div className="flex gap-1 items-center justify-center">
|
||||
<p>{leaderboard.stars}</p>
|
||||
<p>{starCount.toFixed(2)}</p>
|
||||
<StarIcon className="w-[14px] h-[14px]" />
|
||||
</div>
|
||||
) : (
|
||||
|
@ -24,15 +24,26 @@ type Props = {
|
||||
* The score to display.
|
||||
*/
|
||||
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 [baseScore, setBaseScore] = useState<number>(score.baseScore);
|
||||
const [beatSaverMap, setBeatSaverMap] = useState<BeatSaverMap | undefined>();
|
||||
const [isLeaderboardExpanded, setIsLeaderboardExpanded] = useState(false);
|
||||
|
||||
const fetchBeatSaverData = useCallback(async () => {
|
||||
// No need to fetch if no buttons
|
||||
if (!settings?.noScoreButtons) {
|
||||
return;
|
||||
}
|
||||
const beatSaverMapData = await lookupBeatSaverMap(leaderboard.songHash);
|
||||
setBeatSaverMap(beatSaverMapData);
|
||||
}, [leaderboard.songHash]);
|
||||
@ -48,13 +59,20 @@ export default function Score({ player, playerScore }: Props) {
|
||||
}, [fetchBeatSaverData]);
|
||||
|
||||
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 (
|
||||
<div className="pb-2 pt-2">
|
||||
{/* 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} />
|
||||
<ScoreSongInfo leaderboard={leaderboard} beatSaverMap={beatSaverMap} />
|
||||
{settings?.noScoreButtons !== true && (
|
||||
<ScoreButtons
|
||||
leaderboard={leaderboard}
|
||||
beatSaverMap={beatSaverMap}
|
||||
@ -64,6 +82,7 @@ export default function Score({ player, playerScore }: Props) {
|
||||
setBaseScore(score.baseScore);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ScoreStats
|
||||
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