aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2025-07-07 11:48:51 -0700
committerPinapelz <yukais@pinapelz.com>2025-07-07 11:48:51 -0700
commit4fc648449d2275d34a4f94e8e2671d7d05125b1f (patch)
tree3b9f9504bf41caed611978e5cc04813d4789d508
parent7fe146f97ddd3f5a8d0c1a996a73cb296c28b9cc (diff)
implement chart view by ID, allow request by pbOnly
-rw-r--r--README.md2
-rw-r--r--backend/src/index.ts1
-rw-r--r--backend/src/routes/score.ts260
-rw-r--r--frontend/src/App.tsx2
-rw-r--r--frontend/src/components/displays/DancerushScoreDisplay.tsx38
-rw-r--r--frontend/src/components/displays/GenericScoreDisplay.tsx38
-rw-r--r--frontend/src/pages/Chart.tsx231
7 files changed, 520 insertions, 52 deletions
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<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;
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage