diff options
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | backend/src/index.ts | 1 | ||||
| -rw-r--r-- | backend/src/routes/score.ts | 260 | ||||
| -rw-r--r-- | frontend/src/App.tsx | 2 | ||||
| -rw-r--r-- | frontend/src/components/displays/DancerushScoreDisplay.tsx | 38 | ||||
| -rw-r--r-- | frontend/src/components/displays/GenericScoreDisplay.tsx | 38 | ||||
| -rw-r--r-- | frontend/src/pages/Chart.tsx | 231 |
7 files changed, 520 insertions, 52 deletions
@@ -1,6 +1,6 @@ # Mirage -**Mirage** is a lightweight, open-source rhythm game score tracker that doesn’t rely on predefined seeds or chart metadata. It preseves your scores across games — even niche ones. +**Mirage** is a rhythm game score tracker that doesn’t rely on predefined seeds or chart metadata. It preseves your scores across games — even niche ones. - React Typescript - Express - TailwindCSS diff --git a/backend/src/index.ts b/backend/src/index.ts index 114dfac..f51eaa2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -56,6 +56,7 @@ app.get('/api/supportedGames', gameRoutes.handleGetSupportedGames); app.post('/api/uploadScore', requireAuth, scoreRoutes.handleScoreUpload); app.get('/api/scores', requireAuth, scoreRoutes.handleGetScores); app.delete('/api/scores', requireAuth, scoreRoutes.handleScoreDeletion); +app.get('/api/scores/:chartId', requireAuth, scoreRoutes.handleGetScoresByChartId); app.listen(port, () => { console.log(`Server listening on port ${port}`); diff --git a/backend/src/routes/score.ts b/backend/src/routes/score.ts index 54e4784..ffb104a 100644 --- a/backend/src/routes/score.ts +++ b/backend/src/routes/score.ts @@ -67,7 +67,7 @@ export const handleScoreUpload = async ( chartId: chartIdHash, }, }); - if(!chartExists){ + if (!chartExists) { await prisma.charts.create({ data: { gameInternalName: internalGameName, @@ -145,57 +145,254 @@ export const handleGetScores = async ( res: express.Response, ) => { try { - const { userId, internalGameName, pageNum, sortKey, direction } = req.query; + const { userId, internalGameName, pageNum, sortKey, direction, pbOnly } = + req.query; if (!userId || !internalGameName || !pageNum) { return res.status(400).json({ error: "Missing required parameters" }); } - const pageNumber = parseInt(pageNum as string); const gameInternalName = internalGameName as string; const userIdNumber = parseInt(userId as string); const sortKeyString = (sortKey as string) || "timestamp"; const directionString = (direction as string)?.toLowerCase() === "asc" ? "asc" : "desc"; + const pbOnlyFlag = pbOnly === "true"; - const num_pages = Math.ceil( - (await prisma.score.count({ - where: { - gameInternalName, - userId: userIdNumber, - }, - })) / PAGE_SIZE, - ); + if ( + directionString && + directionString !== "asc" && + directionString !== "desc" + ) { + return res.status(400).json({ error: "Invalid direction parameter" }); + } let scores; + let totalScores; - if (sortKeyString === "timestamp") { - scores = await prisma.score.findMany({ + if (pbOnlyFlag) { + // For pbOnly, we need to get the best score for each chart + if (sortKeyString === "timestamp") { + scores = await prisma.$queryRawUnsafe<any[]>( + ` + SELECT DISTINCT ON ("chartId") * + FROM "Score" + WHERE "gameInternalName" = $1 AND "userId" = $2 + ORDER BY "chartId", "timestamp" ${directionString.toUpperCase()} + OFFSET $3 + LIMIT $4 + `, + gameInternalName, + userIdNumber, + (pageNumber - 1) * PAGE_SIZE, + PAGE_SIZE, + ); + } else { + scores = await prisma.$queryRawUnsafe<any[]>( + ` + SELECT DISTINCT ON ("chartId") * + FROM "Score" + WHERE "gameInternalName" = $1 AND "userId" = $2 + ORDER BY "chartId", (data->>'${sortKeyString}')::numeric ${directionString.toUpperCase()} + OFFSET $3 + LIMIT $4 + `, + gameInternalName, + userIdNumber, + (pageNumber - 1) * PAGE_SIZE, + PAGE_SIZE, + ); + } + + // Count distinct charts for pagination + const chartCountResult = await prisma.$queryRawUnsafe<any[]>( + ` + SELECT COUNT(DISTINCT "chartId") as count + FROM "Score" + WHERE "gameInternalName" = $1 AND "userId" = $2 + `, + gameInternalName, + userIdNumber, + ); + totalScores = Number(chartCountResult[0]?.count || 0); + } else { + totalScores = await prisma.score.count({ where: { gameInternalName, userId: userIdNumber, }, - orderBy: { - timestamp: directionString, - }, - skip: (pageNumber - 1) * PAGE_SIZE, - take: PAGE_SIZE, }); - } else { - // everything else attempt to rawsql it - scores = await prisma.$queryRawUnsafe<any[]>( + + if (sortKeyString === "timestamp") { + scores = await prisma.score.findMany({ + where: { + gameInternalName, + userId: userIdNumber, + }, + orderBy: { + timestamp: directionString, + }, + skip: (pageNumber - 1) * PAGE_SIZE, + take: PAGE_SIZE, + }); + } else { + // everything else attempt to rawsql it + scores = await prisma.$queryRawUnsafe<any[]>( + ` + SELECT * FROM "Score" + WHERE "gameInternalName" = $1 AND "userId" = $2 + ORDER BY (data->>'${sortKeyString}')::numeric ${directionString.toUpperCase()} + OFFSET $3 + LIMIT $4 + `, + gameInternalName, + userIdNumber, + (pageNumber - 1) * PAGE_SIZE, + PAGE_SIZE, + ); + } + } + + const num_pages = Math.ceil(totalScores / PAGE_SIZE); + if (!scores) { + return res.status(404).json({ + error: + "No scores found. Either no scores exist or the sortKey provided is invalid for the game, sortKey: " + + sortKeyString, + }); + } + + const safeScores = scores.map((score) => ({ + ...score, + timestamp: + typeof score.timestamp === "bigint" + ? Number(score.timestamp) + : score.timestamp, + })); + + res.status(200).json({ + scores: safeScores, + num_pages, + }); + } catch (error) { + console.error("Failed to fetch scores:", error); + res + .status(500) + .json({ error: "Internal server error. Unable to fetch scores" }); + } +}; + + +export const handleGetScoresByChartId = async ( + req: express.Request, + res: express.Response, +) => { + try { + const { chartId } = req.params; + const { sortKey, direction, pageNum, pbOnly } = req.query; + const chartIdString = chartId as string; + const pageNumber = parseInt(pageNum as string); + const sortKeyString = (sortKey as string) || "timestamp"; + const directionString = + (direction as string)?.toLowerCase() === "asc" ? "asc" : "desc"; + const pbOnlyFlag = pbOnly === "true"; + if ( + directionString && + directionString !== "asc" && + directionString !== "desc" + ) { + return res.status(400).json({ error: "Invalid direction parameter" }); + } + + let scores; + let totalScores; + + if (pbOnlyFlag) { + if (sortKeyString === "timestamp") { + scores = await prisma.$queryRawUnsafe<any[]>( + ` + SELECT DISTINCT ON (s."userId") s.*, u.username + FROM "Score" s + JOIN "User" u ON s."userId" = u.id + WHERE s."chartId" = $1 + ORDER BY s."userId", s."timestamp" ${directionString.toUpperCase()} + OFFSET $2 + LIMIT $3 + `, + chartIdString, + (pageNumber - 1) * PAGE_SIZE, + PAGE_SIZE, + ); + } else { + scores = await prisma.$queryRawUnsafe<any[]>( + ` + SELECT DISTINCT ON (s."userId") s.*, u.username + FROM "Score" s + JOIN "User" u ON s."userId" = u.id + WHERE s."chartId" = $1 + ORDER BY s."userId", (s.data->>'${sortKeyString}')::numeric ${directionString.toUpperCase()} + OFFSET $2 + LIMIT $3 + `, + chartIdString, + (pageNumber - 1) * PAGE_SIZE, + PAGE_SIZE, + ); + } + + // Count distinct users for pagination + const userCountResult = await prisma.$queryRawUnsafe<any[]>( ` - SELECT * FROM "Score" - WHERE "gameInternalName" = $1 AND "userId" = $2 - ORDER BY (data->>'${sortKeyString}')::numeric ${directionString.toUpperCase()} - OFFSET $3 - LIMIT $4 - `, - gameInternalName, - userIdNumber, - (pageNumber - 1) * PAGE_SIZE, - PAGE_SIZE, + SELECT COUNT(DISTINCT "userId") as count + FROM "Score" + WHERE "chartId" = $1 + `, + chartIdString, ); + totalScores = Number(userCountResult[0]?.count || 0); + } else { + totalScores = await prisma.score.count({ + where: { + chartId: chartIdString, + }, + }); + + if (sortKeyString === "timestamp") { + scores = await prisma.score.findMany({ + where: { + chartId: chartIdString, + }, + include: { + user: { + select: { + username: true, + }, + }, + }, + orderBy: { + timestamp: directionString, + }, + skip: (pageNumber - 1) * PAGE_SIZE, + take: PAGE_SIZE, + }); + } else { + // everything else attempt to rawsql it + scores = await prisma.$queryRawUnsafe<any[]>( + ` + SELECT s.*, u.username FROM "Score" s + JOIN "User" u ON s."userId" = u.id + WHERE s."chartId" = $1 + ORDER BY (s.data->>'${sortKeyString}')::numeric ${directionString.toUpperCase()} + OFFSET $2 + LIMIT $3 + `, + chartIdString, + (pageNumber - 1) * PAGE_SIZE, + PAGE_SIZE, + ); + } } + + const num_pages = Math.ceil(totalScores / PAGE_SIZE); if (!scores) { return res.status(404).json({ error: @@ -206,6 +403,7 @@ export const handleGetScores = async ( const safeScores = scores.map((score) => ({ ...score, + username: score.user?.username || score.username, timestamp: typeof score.timestamp === "bigint" ? Number(score.timestamp) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f6fffca..9c4e8bd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import Register from "./pages/Register"; import Import from "./pages/Import"; import Home from "./pages/Home"; import Score from "./pages/Score"; +import Chart from "./pages/Chart"; function App() { return ( @@ -17,6 +18,7 @@ function App() { <Route path="/import" element={<Import />} /> <Route path="/home" element={<Home />} /> <Route path="/score" element={<Score />} /> + <Route path="/chart" element={<Chart />} /> </Routes> </AuthProvider> ); diff --git a/frontend/src/components/displays/DancerushScoreDisplay.tsx b/frontend/src/components/displays/DancerushScoreDisplay.tsx index b030db7..4799787 100644 --- a/frontend/src/components/displays/DancerushScoreDisplay.tsx +++ b/frontend/src/components/displays/DancerushScoreDisplay.tsx @@ -7,6 +7,7 @@ interface Score { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; timestamp: string | number; + username?: string; } interface ScoreDisplayProps { @@ -16,6 +17,8 @@ interface ScoreDisplayProps { sortDirection: "asc" | "desc"; onSort: (field: string) => void; onDelete?: (scoreId: number) => void; + showUsername?: boolean; + hideTitleArtist?: boolean; } const DancerushScoreDisplay: React.FC<ScoreDisplayProps> = ({ @@ -25,6 +28,8 @@ const DancerushScoreDisplay: React.FC<ScoreDisplayProps> = ({ sortDirection, onSort, onDelete, + showUsername = false, + hideTitleArtist = false, }) => { // Key mappings for better display names. Hit or miss const keyDisplayNames: Record<string, string> = { @@ -42,6 +47,7 @@ const DancerushScoreDisplay: React.FC<ScoreDisplayProps> = ({ good: "Good", bad: "Bad", miss: "Miss", + username: "Username", }; const primaryKeys = ["title", "artist", "song"]; @@ -228,9 +234,8 @@ const DancerushScoreDisplay: React.FC<ScoreDisplayProps> = ({ // Prioritize important keys for table display const tableKeys = [ - "title", - "song", - "artist", + ...(hideTitleArtist ? [] : ["title", "song", "artist"]), + ...(showUsername ? ["username"] : []), "score", "difficulty", "lamp", @@ -277,12 +282,21 @@ const DancerushScoreDisplay: React.FC<ScoreDisplayProps> = ({ {/* Primary Info */} <div className="flex items-start justify-between mb-4"> <div className="flex-1 min-w-0"> - <h3 className="text-lg font-semibold text-white mb-1 break-words leading-tight"> - {score.title || score.song || "Unknown Title"} - </h3> - {score.artist && ( - <p className="text-slate-400 text-sm break-words leading-tight"> - {score.artist} + {!hideTitleArtist && ( + <> + <h3 className="text-lg font-semibold text-white mb-1 break-words leading-tight"> + {score.title || score.song || "Unknown Title"} + </h3> + {score.artist && ( + <p className="text-slate-400 text-sm break-words leading-tight"> + {score.artist} + </p> + )} + </> + )} + {showUsername && score.username && ( + <p className="text-slate-500 text-xs break-words leading-tight"> + by {score.username} </p> )} </div> @@ -412,9 +426,13 @@ const DancerushScoreDisplay: React.FC<ScoreDisplayProps> = ({ : score[key], ).toLocaleDateString()} </span> + ) : key === "username" ? ( + <span className="text-violet-400 text-sm font-medium"> + {score[key] || "Unknown"} + </span> ) : ( <span - className={`${key === "title" || key === "song" ? "text-white font-medium" : key === "score" ? "text-white font-medium" : "text-slate-300"}`} + className={`${(key === "title" || key === "song") && !hideTitleArtist ? "text-white font-medium" : key === "score" ? "text-white font-medium" : "text-slate-300"}`} > {renderValue(score[key], key)} </span> diff --git a/frontend/src/components/displays/GenericScoreDisplay.tsx b/frontend/src/components/displays/GenericScoreDisplay.tsx index 45bf1ca..66bfe2a 100644 --- a/frontend/src/components/displays/GenericScoreDisplay.tsx +++ b/frontend/src/components/displays/GenericScoreDisplay.tsx @@ -5,6 +5,7 @@ interface Score { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; timestamp: string | number; + username?: string; } interface ScoreDisplayProps { @@ -14,6 +15,8 @@ interface ScoreDisplayProps { sortDirection: "asc" | "desc"; onSort: (field: string) => void; onDelete?: (scoreId: number) => void; + showUsername?: boolean; + hideTitleArtist?: boolean; } const ScoreDisplay: React.FC<ScoreDisplayProps> = ({ @@ -23,6 +26,8 @@ const ScoreDisplay: React.FC<ScoreDisplayProps> = ({ sortDirection, onSort, onDelete, + showUsername = false, + hideTitleArtist = false, }) => { // Key mappings for better display names. Hit or miss const keyDisplayNames: Record<string, string> = { @@ -54,6 +59,7 @@ const ScoreDisplay: React.FC<ScoreDisplayProps> = ({ playcount: "Play Count", date: "Date", time: "Time", + username: "Username", }; const primaryKeys = ["title", "artist", "song"]; @@ -224,9 +230,8 @@ const ScoreDisplay: React.FC<ScoreDisplayProps> = ({ // Prioritize important keys for table display const tableKeys = [ - "title", - "song", - "artist", + ...(hideTitleArtist ? [] : ["title", "song", "artist"]), + ...(showUsername ? ["username"] : []), "score", "difficulty", "lamp", @@ -273,12 +278,21 @@ const ScoreDisplay: React.FC<ScoreDisplayProps> = ({ {/* Primary Info */} <div className="flex items-start justify-between mb-4"> <div className="flex-1 min-w-0"> - <h3 className="text-lg font-semibold text-white mb-1 break-words leading-tight"> - {score.title || score.song || "Unknown Title"} - </h3> - {score.artist && ( - <p className="text-slate-400 text-sm break-words leading-tight"> - {score.artist} + {!hideTitleArtist && ( + <> + <h3 className="text-lg font-semibold text-white mb-1 break-words leading-tight"> + {score.title || score.song || "Unknown Title"} + </h3> + {score.artist && ( + <p className="text-slate-400 text-sm break-words leading-tight"> + {score.artist} + </p> + )} + </> + )} + {showUsername && score.username && ( + <p className="text-slate-500 text-xs break-words leading-tight"> + by {score.username} </p> )} </div> @@ -407,9 +421,13 @@ const ScoreDisplay: React.FC<ScoreDisplayProps> = ({ : score[key], ).toLocaleDateString()} </span> + ) : key === "username" ? ( + <span className="text-violet-400 text-sm font-medium"> + {score[key] || "Unknown"} + </span> ) : ( <span - className={`${key === "title" || key === "song" ? "text-white font-medium" : key === "score" ? "text-white font-medium" : "text-slate-300"}`} + className={`${(key === "title" || key === "song") && !hideTitleArtist ? "text-white font-medium" : key === "score" ? "text-white font-medium" : "text-slate-300"}`} > {renderValue(score[key], key)} </span> diff --git a/frontend/src/pages/Chart.tsx b/frontend/src/pages/Chart.tsx new file mode 100644 index 0000000..4271abe --- /dev/null +++ b/frontend/src/pages/Chart.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState, useCallback } from "react"; +import { useAuth } from "../contexts/AuthContext"; +import { useNavigate } from "react-router"; +import { NavBar } from "../components/NavBar"; +import SessionExpiredPopup from "../components/SessionExpiredPopup"; +import ScoreDisplay from "../components/displays/GenericScoreDisplay"; +import DancerushScoreDisplay from "../components/displays/DancerushScoreDisplay"; +type SortField = string; +type SortDirection = "asc" | "desc"; + +import { getFilterOptions } from "../types/constants"; + +const Chart = () => { + const { user, isLoading, logout } = useAuth(); + const navigate = useNavigate(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [scores, setScores] = useState<any[]>([]); + const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [numPages, setNumPages] = useState(1); + const [viewMode, setViewMode] = useState<"cards" | "table">("cards"); + const [sortField, setSortField] = useState<SortField>(""); + const [sortDirection, setSortDirection] = useState<SortDirection>("asc"); + const [requestOrder, setRequestOrder] = useState<string>("timestamp"); + const chartIdHash = new URLSearchParams(window.location.search).get("chartId") || ""; + if (!chartIdHash) { + navigate("/home"); + } + + const gameName = + new URLSearchParams(window.location.search).get("game") || "dancerush"; + + const handleLogout = async () => { + try { + await logout(); + navigate("/"); + } catch (error) { + console.error("Logout failed:", error); + alert("Network error during logout. Please try again."); + } + }; + + const renderRequestFilterMenu = () => { + const filterOptions = getFilterOptions(gameName); + return ( + <div className="flex items-center space-x-2 bg-slate-900/50 backdrop-blur-sm rounded-xl p-1 border border-slate-800/50"> + {filterOptions.map((option) => ( + <button + key={option.value} + onClick={() => setRequestOrder(option.value)} + className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${ + requestOrder === option.value + ? "bg-violet-600 text-white shadow-lg shadow-violet-500/25" + : "text-slate-300 hover:text-white hover:bg-slate-800/50" + }`} + > + {option.label} + </button> + ))} + </div> + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const flattenScoreData = (score: any) => { + const flat = { ...score, ...score.data }; + delete flat.data; + delete flat.gameInternalName; + return flat; + }; + + const fetchScores = useCallback( + async (pageNum: number) => { + if (!user) return; + + setLoading(true); + try { + const url = new URL(import.meta.env.VITE_API_URL + "/scores/" + chartIdHash); + url.searchParams.append("pageNum", pageNum.toString()); + url.searchParams.append("sortKey", requestOrder); + url.searchParams.append("direction", "asc"); + url.searchParams.append("pbOnly", "true"); + + const response = await fetch(url.toString(), {credentials: 'include'}); + if (!response.ok) throw new Error("Failed to fetch scores"); + const data = await response.json(); + const flattened = data.scores.map(flattenScoreData); + setScores(flattened); + setNumPages(data.num_pages); + setCurrentPage(pageNum); + } catch (error) { + console.error("Failed to load scores:", error); + alert("Failed to load scores. Please refresh the page."); + } finally { + setLoading(false); + } + }, + [user, requestOrder, chartIdHash], + ); + + useEffect(() => { + if (user) fetchScores(1); + }, [user, fetchScores]); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortDirection("desc"); + } + }; + + if (!user) { + return <SessionExpiredPopup />; + } + + if (isLoading || loading) { + return ( + <div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center"> + <div className="text-center"> + <div className="w-12 h-12 border-4 border-violet-500 border-t-transparent rounded-full animate-spin mx-auto mb-6"></div> + <p className="text-slate-300 text-lg">Loading scores...</p> + </div> + </div> + ); + } + + return ( + <div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-white"> + <NavBar user={user} handleLogout={handleLogout} currentPage="score" /> + <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> + <div className="mb-12"> + <div className="flex items-center justify-between mb-4"> + <div> + <h1 className="text-4xl font-bold bg-gradient-to-r from-violet-400 to-violet-600 bg-clip-text text-transparent"> + {scores.length === 0 ? "Unknown Chart" : scores[0].title} + </h1> + <h2 className="text-xl text-slate-300 mt-2"> + {scores.length === 0 ? "Unknown Artist" : scores[0].artist} + </h2> + </div> + <div className="flex items-center space-x-2 bg-slate-900/50 backdrop-blur-sm rounded-xl p-1 border border-slate-800/50"> + <button + onClick={() => setViewMode("cards")} + className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${ + viewMode === "cards" + ? "bg-violet-600 text-white shadow-lg shadow-violet-500/25" + : "text-slate-300 hover:text-white hover:bg-slate-800/50" + }`} + > + Cards + </button> + <button + onClick={() => setViewMode("table")} + className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${ + viewMode === "table" + ? "bg-violet-600 text-white shadow-lg shadow-violet-500/25" + : "text-slate-300 hover:text-white hover:bg-slate-800/50" + }`} + > + Table + </button> + </div> + </div> + + {/* Filter Menu */} + <div className="flex items-center justify-between mb-6"> + <div className="flex items-center space-x-4"> + {renderRequestFilterMenu()} + </div> + </div> + </div> + + {(() => { + switch (gameName) { + case "dancerush": + return ( + <DancerushScoreDisplay + scores={scores} + viewMode={viewMode} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + showUsername={true} + hideTitleArtist={true} + /> + ); + default: + return ( + <ScoreDisplay + scores={scores} + viewMode={viewMode} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + showUsername={true} + hideTitleArtist={true} + /> + ); + } + })()} + + {numPages > 1 && ( + <div className="flex justify-center mt-12"> + <div className="flex space-x-2 bg-slate-900/50 backdrop-blur-sm rounded-xl p-2 border border-slate-800/50"> + {[...Array(numPages)].map((_, i) => ( + <button + key={i} + onClick={() => fetchScores(i + 1)} + className={`px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${ + currentPage === i + 1 + ? "bg-violet-600 text-white shadow-lg shadow-violet-500/25" + : "text-slate-300 hover:text-white hover:bg-slate-800/50" + }`} + > + {i + 1} + </button> + ))} + </div> + </div> + )} + <p className="text-slate-400 mt-4 text-lg"> + Displaying {scores.length} scores • Page {currentPage} of {numPages} + </p> + </main> + </div> + ); +}; + +export default Chart; |
