From 4fc648449d2275d34a4f94e8e2671d7d05125b1f Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Mon, 7 Jul 2025 11:48:51 -0700 Subject: implement chart view by ID, allow request by pbOnly --- README.md | 2 +- backend/src/index.ts | 1 + backend/src/routes/score.ts | 260 ++++++++++++++++++--- frontend/src/App.tsx | 2 + .../components/displays/DancerushScoreDisplay.tsx | 38 ++- .../components/displays/GenericScoreDisplay.tsx | 38 ++- frontend/src/pages/Chart.tsx | 231 ++++++++++++++++++ 7 files changed, 520 insertions(+), 52 deletions(-) create mode 100644 frontend/src/pages/Chart.tsx diff --git a/README.md b/README.md index af1f2f8..e2d213a 100644 --- a/README.md +++ b/README.md @@ -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( + ` + 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( + ` + 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( + ` + 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( + + 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( + ` + 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( + ` + 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( + ` + 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( ` - 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( + ` + 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() { } /> } /> } /> + } /> ); 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 = ({ @@ -25,6 +28,8 @@ const DancerushScoreDisplay: React.FC = ({ sortDirection, onSort, onDelete, + showUsername = false, + hideTitleArtist = false, }) => { // Key mappings for better display names. Hit or miss const keyDisplayNames: Record = { @@ -42,6 +47,7 @@ const DancerushScoreDisplay: React.FC = ({ good: "Good", bad: "Bad", miss: "Miss", + username: "Username", }; const primaryKeys = ["title", "artist", "song"]; @@ -228,9 +234,8 @@ const DancerushScoreDisplay: React.FC = ({ // 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 = ({ {/* Primary Info */}
-

- {score.title || score.song || "Unknown Title"} -

- {score.artist && ( -

- {score.artist} + {!hideTitleArtist && ( + <> +

+ {score.title || score.song || "Unknown Title"} +

+ {score.artist && ( +

+ {score.artist} +

+ )} + + )} + {showUsername && score.username && ( +

+ by {score.username}

)}
@@ -412,9 +426,13 @@ const DancerushScoreDisplay: React.FC = ({ : score[key], ).toLocaleDateString()} + ) : key === "username" ? ( + + {score[key] || "Unknown"} + ) : ( {renderValue(score[key], key)} 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 = ({ @@ -23,6 +26,8 @@ const ScoreDisplay: React.FC = ({ sortDirection, onSort, onDelete, + showUsername = false, + hideTitleArtist = false, }) => { // Key mappings for better display names. Hit or miss const keyDisplayNames: Record = { @@ -54,6 +59,7 @@ const ScoreDisplay: React.FC = ({ playcount: "Play Count", date: "Date", time: "Time", + username: "Username", }; const primaryKeys = ["title", "artist", "song"]; @@ -224,9 +230,8 @@ const ScoreDisplay: React.FC = ({ // 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 = ({ {/* Primary Info */}
-

- {score.title || score.song || "Unknown Title"} -

- {score.artist && ( -

- {score.artist} + {!hideTitleArtist && ( + <> +

+ {score.title || score.song || "Unknown Title"} +

+ {score.artist && ( +

+ {score.artist} +

+ )} + + )} + {showUsername && score.username && ( +

+ by {score.username}

)}
@@ -407,9 +421,13 @@ const ScoreDisplay: React.FC = ({ : score[key], ).toLocaleDateString()} + ) : key === "username" ? ( + + {score[key] || "Unknown"} + ) : ( {renderValue(score[key], key)} 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([]); + 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(""); + const [sortDirection, setSortDirection] = useState("asc"); + const [requestOrder, setRequestOrder] = useState("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 ( +
+ {filterOptions.map((option) => ( + + ))} +
+ ); + } + + // 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 ; + } + + if (isLoading || loading) { + return ( +
+
+
+

Loading scores...

+
+
+ ); + } + + return ( +
+ +
+
+
+
+

+ {scores.length === 0 ? "Unknown Chart" : scores[0].title} +

+

+ {scores.length === 0 ? "Unknown Artist" : scores[0].artist} +

+
+
+ + +
+
+ + {/* Filter Menu */} +
+
+ {renderRequestFilterMenu()} +
+
+
+ + {(() => { + switch (gameName) { + case "dancerush": + return ( + + ); + default: + return ( + + ); + } + })()} + + {numPages > 1 && ( +
+
+ {[...Array(numPages)].map((_, i) => ( + + ))} +
+
+ )} +

+ Displaying {scores.length} scores • Page {currentPage} of {numPages} +

+
+
+ ); +}; + +export default Chart; -- cgit v1.2.3