diff options
| -rw-r--r-- | backend/src/index.ts | 2 | ||||
| -rw-r--r-- | backend/src/routes/user.ts | 43 | ||||
| -rw-r--r-- | frontend/package.json | 1 | ||||
| -rw-r--r-- | frontend/pnpm-lock.yaml | 21 | ||||
| -rw-r--r-- | frontend/src/components/Heatmap.tsx | 36 | ||||
| -rw-r--r-- | frontend/src/pages/Profile.tsx | 88 |
6 files changed, 183 insertions, 8 deletions
diff --git a/backend/src/index.ts b/backend/src/index.ts index ed9c45a..c0089c2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -51,6 +51,8 @@ app.post('/api/logout', requireAuth, authRoutes.handleLogout); app.get('/api/me', userRoutes.handleMeRoute); app.get('/api/session', userRoutes.handleGetCurrentSession); +app.get('/api/heatmap', requireAuth, userRoutes.handleGetScoresHeatmap); + app.get('/api/supportedGames', gameRoutes.handleGetSupportedGames); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 7db25db..fc03e68 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -46,3 +46,46 @@ export const handleGetCurrentSession = async (req: express.Request, res: expres res.status(500).json({ error: 'Internal server error' }); } } + +export const handleGetScoresHeatmap = async (req: express.Request, res: express.Response) => { + const { userId, gameInternalName } = req.query; + if (!userId) { + return res.status(400).json({ error: "Must specify userId to lookup parameters" }); + } + try { + const user = await prisma.user.findUnique({ + where: { id: parseInt(userId as string) }, + select: { id: true, username: true, isAdmin: true } + }); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + const oneYearAndOneDay = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); + const unixMs = Math.floor(oneYearAndOneDay.getTime() / 1000); + + const scores = await prisma.score.findMany({ + where: { + userId: parseInt(userId as string), + timestamp: { gte: unixMs }, + ...(gameInternalName && { gameInternalName: gameInternalName as string }) + }, + orderBy: { timestamp: 'desc' }, + select: { + timestamp: true + } + }).then(scores => scores.map(score => ({ + ...score, + timestamp: Number(score.timestamp) + }))) + + res.json({ + "username": user.username, + "isAdmin": user.isAdmin, + scores + }); + } catch (error) { + console.error('Session check error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/frontend/package.json b/frontend/package.json index af7efd0..ef68bfd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@uiw/react-heat-map": "^2.3.3", "crypto-js": "^4.2.0", "react": "^19.1.1", + "react-device-detect": "^2.2.3", "react-dom": "^19.1.1", "react-router": "^7.9.1", "tailwindcss": "^4.1.13" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 80bdfb1..ac95a81 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: react: specifier: ^19.1.1 version: 19.1.1 + react-device-detect: + specifier: ^2.2.3 + version: 2.2.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) @@ -1186,6 +1189,12 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-device-detect@2.2.3: + resolution: {integrity: sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==} + peerDependencies: + react: '>= 0.14.0' + react-dom: '>= 0.14.0' + react-dom@19.1.1: resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} peerDependencies: @@ -1301,6 +1310,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@1.0.41: + resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} + hasBin: true + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -2385,6 +2398,12 @@ snapshots: queue-microtask@1.2.3: {} + react-device-detect@2.2.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + ua-parser-js: 1.0.41 + react-dom@19.1.1(react@19.1.1): dependencies: react: 19.1.1 @@ -2502,6 +2521,8 @@ snapshots: typescript@5.9.2: {} + ua-parser-js@1.0.41: {} + update-browserslist-db@1.1.3(browserslist@4.26.2): dependencies: browserslist: 4.26.2 diff --git a/frontend/src/components/Heatmap.tsx b/frontend/src/components/Heatmap.tsx new file mode 100644 index 0000000..6720481 --- /dev/null +++ b/frontend/src/components/Heatmap.tsx @@ -0,0 +1,36 @@ +import HeatMap from '@uiw/react-heat-map'; + +export type HeatmapObject = { + date: string, + count: number +} + +export type HeatmapData = { + data: HeatmapObject[] +} + +const Heatmap = (data : HeatmapData) => { + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + console.log(data.data); + + return ( + <HeatMap + value={data.data} + width={800} + startDate={oneYearAgo} + style={{ + color: '#ffffff' + }} + panelColors={{ + 0: '#161b22', + 2: '#0e4429', + 4: '#006d32', + 10: '#26a641', + 20: '#39d353' + }} + /> + ) + +} +export default Heatmap; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 184681d..4ab5995 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,19 +1,74 @@ -import { useNavigate } from 'react-router'; -import LoadingDisplay from "../components/LoadingDisplay"; +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router"; +import { isBrowser } from "react-device-detect"; +import LoadingDisplay from "../components/LoadingDisplay"; import SessionExpiredPopup from "../components/SessionExpiredPopup"; -import { NavBar } from '../components/NavBar'; +import { NavBar } from "../components/NavBar"; import { useAuth } from "../contexts/AuthContext"; - - +import Heatmap from "../components/Heatmap"; +import type { HeatmapData } from "../components/Heatmap"; const Profile = () => { const { user, isLoading, logout } = useAuth(); + const targetUser = + new URLSearchParams(window.location.search).get("userId") || ""; // looking at profile of this user const navigate = useNavigate(); + const [fetchingHeatmapData, setFetchingHeatmapData] = useState(false); + const [heatmapData, setHeatmapData] = useState<HeatmapData>({ data: [] }); + + useEffect(() => { + if (targetUser) { + setFetchingHeatmapData(true); + const fetchHeatmapData = async () => { + try { + const response = await fetch( + new URL( + import.meta.env.VITE_API_URL + "/heatmap?userId=" + targetUser, + ), + { credentials: "include" }, + ); + const data = await response.json(); + return data; + } catch (error) { + setFetchingHeatmapData(false); + console.error("Failed to fetch heatmap data:", error); + throw error; + } + }; + fetchHeatmapData().then((data) => { + const heatmapDates: { [key: string]: number } = {}; + for (let i = 0; i < data.scores.length; i++) { + const date = new Date(data.scores[i].timestamp); + const dateString = date.toDateString(); + if (!heatmapDates[dateString]) { + heatmapDates[dateString] = 1; + } else { + heatmapDates[dateString] += 1; + } + } + setHeatmapData({ + data: Object.entries(heatmapDates).map(([date, count]) => ({ + date, + count, + })), + }); + setFetchingHeatmapData(false); + }); + } + }, [targetUser]); + + if (!targetUser) { + navigate("/"); + } - if (isLoading) { + if (isLoading || fetchingHeatmapData) { return <LoadingDisplay message="Loading Profile Page..." />; } + if (!user) { + return <SessionExpiredPopup />; + } + const handleLogout = async () => { try { await logout(); @@ -30,8 +85,25 @@ const Profile = () => { return ( <div className="min-h-screen bg-slate-950"> - <NavBar user={user} handleLogout={handleLogout} currentPage="import"/> - <h1>Profile</h1> + <NavBar user={user} handleLogout={handleLogout} currentPage="" /> + + {/* Main Content */} + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-8"> + {/* Header */} + <div className="mb-6 sm:mb-8"> + <h1 className="text-2xl sm:text-3xl font-bold text-white mb-2"> + {user.username} + </h1> + <p className="text-sm sm:text-base text-slate-400"> + This is a profile page for {user.username} + </p> + </div> + {isBrowser ? ( + <div className="flex flex-col items-center justify-center"> + <Heatmap data={heatmapData.data} /> + </div> + ) : null} + </div> </div> ); }; |
