diff options
| -rw-r--r-- | backend/src/config/constants.ts | 2 | ||||
| -rw-r--r-- | backend/src/routes/score.ts | 136 | ||||
| -rw-r--r-- | frontend/src/pages/Score.tsx | 54 |
3 files changed, 133 insertions, 59 deletions
diff --git a/backend/src/config/constants.ts b/backend/src/config/constants.ts index 5ade0a7..8ab5cc7 100644 --- a/backend/src/config/constants.ts +++ b/backend/src/config/constants.ts @@ -1 +1 @@ -export const PAGE_SIZE = 30; +export const PAGE_SIZE = 100; diff --git a/backend/src/routes/score.ts b/backend/src/routes/score.ts index 6e4dc19..e0f2281 100644 --- a/backend/src/routes/score.ts +++ b/backend/src/routes/score.ts @@ -1,29 +1,39 @@ -import express from 'express'; -import { prisma } from '../config/db'; -import { PAGE_SIZE } from '../config/constants'; +import express from "express"; +import { prisma } from "../config/db"; +import { PAGE_SIZE } from "../config/constants"; -export const handleScoreUpload = async (req: express.Request, res: express.Response) => { +export const handleScoreUpload = async ( + req: express.Request, + res: express.Response, +) => { try { const { meta, scores } = req.body; const userId = req.session.userId; if (!userId) { - return res.status(401).json({ error: 'Unauthorized. Please log in to upload scores.' }); + return res + .status(401) + .json({ error: "Unauthorized. Please log in to upload scores." }); } // Basic universal validation if (!meta || !meta.game || !meta.service || !scores) { - return res.status(400).json({ error: 'Invalid request format. Expected meta with game/service and scores array' }); + return res.status(400).json({ + error: + "Invalid request format. Expected meta with game/service and scores array", + }); } let game = await prisma.game.findUnique({ - where: { internalName: meta.game } + where: { internalName: meta.game }, }); if (!game) { game = await prisma.game.findFirst({ - where: { formattedName: meta.game } + where: { formattedName: meta.game }, }); } if (!game) { - return res.status(400).json({ error: `Game '${meta.game}' is not supported. Ensure that you are using the case-sensitive version of either the internal name or formatted name` }); + return res.status(400).json({ + error: `Game '${meta.game}' is not supported. Ensure that you are using the case-sensitive version of either the internal name or formatted name`, + }); } const internalGameName = game.internalName; const scoresArray = Array.isArray(scores) ? scores : [scores]; @@ -38,9 +48,9 @@ export const handleScoreUpload = async (req: express.Request, res: express.Respo gameInternalName: internalGameName, userId: userId, data: { - equals: scoreData - } - } + equals: scoreData, + }, + }, }); if (existingScore) { @@ -50,92 +60,114 @@ export const handleScoreUpload = async (req: express.Request, res: express.Respo gameInternalName: internalGameName, userId: userId, timestamp: scoreData.timestamp, - data: scoreData + data: scoreData, }); } } - const createdScores = scoresToCreate.length > 0 - ? await prisma.score.createMany({ - data: scoresToCreate - }) - : { count: 0 }; + const createdScores = + scoresToCreate.length > 0 + ? await prisma.score.createMany({ + data: scoresToCreate, + }) + : { count: 0 }; res.status(200).json({ - message: 'Score upload processed successfully', + message: "Score upload processed successfully", game: meta.game, service: meta.service, scoreCount: createdScores.count, skippedCount: skippedCount, - totalProcessed: scoresArray.length + totalProcessed: scoresArray.length, }); - } catch (error) { - console.error('Score upload endpoint error:', error); - res.status(500).json({ error: 'Internal server error. Unable to process score upload' }); + console.error("Score upload endpoint error:", error); + res + .status(500) + .json({ error: "Internal server error. Unable to process score upload" }); } -} +}; -export const handleGetScores = async (req: express.Request, res: express.Response) => { +export const handleGetScores = async ( + req: express.Request, + res: express.Response, +) => { try { const { userId, internalGameName, pageNum, sortKey, direction } = req.query; if (!userId || !internalGameName || !pageNum) { - return res.status(400).json({ error: 'Missing required parameters' }); + 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 sortKeyString = (sortKey as string) || "timestamp"; + const directionString = + (direction as string)?.toLowerCase() === "asc" ? "asc" : "desc"; - const num_pages = Math.ceil(await prisma.score.count({ - where: { - gameInternalName, - userId: userIdNumber - } - }) / PAGE_SIZE); + const num_pages = Math.ceil( + (await prisma.score.count({ + where: { + gameInternalName, + userId: userIdNumber, + }, + })) / PAGE_SIZE, + ); let scores; - if (sortKeyString === 'timestamp') { + if (sortKeyString === "timestamp") { scores = await prisma.score.findMany({ where: { gameInternalName, - userId: userIdNumber + userId: userIdNumber, }, orderBy: { - timestamp: directionString + timestamp: directionString, }, skip: (pageNumber - 1) * PAGE_SIZE, - take: PAGE_SIZE + take: PAGE_SIZE, }); - } else if (sortKeyString === 'score') { - // raw SQL for JSON field ordering - scores = await prisma.$queryRawUnsafe<any[]>(` + } else { + // everything else attempt to rawsql it + scores = await prisma.$queryRawUnsafe<any[]>( + ` SELECT * FROM "Score" WHERE "gameInternalName" = $1 AND "userId" = $2 - ORDER BY (data->>'score')::numeric ${directionString.toUpperCase()} + ORDER BY (data->>'${sortKeyString}')::numeric ${directionString.toUpperCase()} OFFSET $3 LIMIT $4 - `, gameInternalName, userIdNumber, (pageNumber - 1) * PAGE_SIZE, PAGE_SIZE); - } else { - return res.status(400).json({ error: 'Invalid sort key' }); + `, + gameInternalName, + userIdNumber, + (pageNumber - 1) * PAGE_SIZE, + 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 => ({ + const safeScores = scores.map((score) => ({ ...score, - timestamp: typeof score.timestamp === 'bigint' - ? Number(score.timestamp) - : score.timestamp + timestamp: + typeof score.timestamp === "bigint" + ? Number(score.timestamp) + : score.timestamp, })); res.status(200).json({ scores: safeScores, - num_pages + num_pages, }); } catch (error) { - console.error('Failed to fetch scores:', error); - res.status(500).json({ error: 'Internal server error. Unable to fetch scores' }); + console.error("Failed to fetch scores:", error); + res + .status(500) + .json({ error: "Internal server error. Unable to fetch scores" }); } }; diff --git a/frontend/src/pages/Score.tsx b/frontend/src/pages/Score.tsx index 8e16a86..2bf1a2b 100644 --- a/frontend/src/pages/Score.tsx +++ b/frontend/src/pages/Score.tsx @@ -20,6 +20,7 @@ const Score = () => { 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 gameName = new URLSearchParams(window.location.search).get("game") || "dancerush"; @@ -33,6 +34,37 @@ const Score = () => { alert("Network error during logout. Please try again."); } }; + + const renderRequestFilterMenu = () => { + if (gameName === "dancerush") { + const filterOptions = [ + { value: "timestamp", label: "Recent" }, + { value: "score", label: "Score" }, + { value: "lamp", label: "Rank" }, + { value: "lamo_diff", label: "Difficulty" }, + ]; + + 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> + ); + } + return null; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const flattenScoreData = (score: any) => { const flat = { ...score, ...score.data }; @@ -51,7 +83,7 @@ const Score = () => { url.searchParams.append("userId", user.id); url.searchParams.append("internalGameName", gameName); url.searchParams.append("pageNum", pageNum.toString()); - url.searchParams.append("sortKey", 'timestamp'); + url.searchParams.append("sortKey", requestOrder); url.searchParams.append("direction", "asc"); const response = await fetch(url.toString()); @@ -68,7 +100,7 @@ const Score = () => { setLoading(false); } }, - [user], + [user, gameName, requestOrder], ); useEffect(() => { @@ -101,7 +133,7 @@ const Score = () => { 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"/> + <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"> @@ -131,9 +163,16 @@ const Score = () => { </button> </div> </div> - <p className="text-slate-400 text-lg"> - Displaying {scores.length} scores • Page {currentPage} of {numPages} - </p> + + {/* Filter Menu */} + <div className="flex items-center justify-between mb-6"> + <div className="flex items-center space-x-4"> + <span className="text-slate-300 text-sm font-medium"> + Sort by: + </span> + {renderRequestFilterMenu()} + </div> + </div> </div> {(() => { @@ -180,6 +219,9 @@ const Score = () => { </div> </div> )} + <p className="text-slate-400 mt-4 text-lg"> + Displaying {scores.length} scores • Page {currentPage} of {numPages} + </p> </main> </div> ); |
