diff options
| author | Pinapelz <yukais@pinapelz.com> | 2025-07-07 11:48:51 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2025-07-07 11:48:51 -0700 |
| commit | 4fc648449d2275d34a4f94e8e2671d7d05125b1f (patch) | |
| tree | 3b9f9504bf41caed611978e5cc04813d4789d508 /frontend/src | |
| parent | 7fe146f97ddd3f5a8d0c1a996a73cb296c28b9cc (diff) | |
implement chart view by ID, allow request by pbOnly
Diffstat (limited to 'frontend/src')
| -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 |
4 files changed, 289 insertions, 20 deletions
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; |
