diff --git a/public/index.html b/public/index.html index cd59e32..9b0eae4 100644 --- a/public/index.html +++ b/public/index.html @@ -9,7 +9,7 @@ - + diff --git a/src/App.js b/src/App.js index 573589a..2da7c43 100644 --- a/src/App.js +++ b/src/App.js @@ -19,6 +19,7 @@ import AskREC from "./components/AskREC"; import Events from "./components/Events/Events"; import GetStarted from "./components/GetStarted/GetStarted"; import ContentsPage from "./components/GetStarted/ContentsPage"; +import Leaderboard from "./components/Leaderboard/Leaderboard"; import NotFound from "./components/NotFound"; import React from "react"; import { LoadingProvider } from "./context/LoadingContext"; @@ -47,16 +48,14 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> - } - /> + } /> {/* private routes */} }> diff --git a/src/api/leaderboard.js b/src/api/leaderboard.js new file mode 100644 index 0000000..f5b755d --- /dev/null +++ b/src/api/leaderboard.js @@ -0,0 +1,27 @@ +import axios from "./axios"; + +export const getLeaderboard = async ({ + platform = "codeforces", + search = "", + batch = "", + showInactive = false, +} = {}) => { + const params = {}; + if (platform) params.platform = platform; + if (search) params.search = search; + if (batch && batch !== "all") params.batch = batch; + if (showInactive) params["show_inactive"] = "true"; + + const response = await axios.get("/leaderboard/", { params }); + return response.data; +}; + +export const refreshLeaderboard = async () => { + const response = await axios.post("/leaderboard/refresh/"); + return response.data; +}; + +export const getLeaderboardCooldown = async () => { + const response = await axios.get("/leaderboard/cooldown/"); + return response.data; +}; diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 5a1750b..379cca9 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -1,5 +1,11 @@ import React, { useState, useEffect } from "react"; -import { Outlet, Link, NavLink, useNavigate, useLocation } from "react-router-dom"; +import { + Outlet, + Link, + NavLink, + useNavigate, + useLocation, +} from "react-router-dom"; import useAuth from "../hooks/useAuth"; import { Box, @@ -36,13 +42,19 @@ const Layout = () => { if (user && location.pathname !== "/profile/edit") { try { const profile = await getProfile(); - const isNameMissing = !profile.name || (typeof profile.name === "string" && profile.name.trim() === ""); - const isCollegeMissing = !profile.college || (typeof profile.college === "string" && profile.college.trim() === ""); + const isNameMissing = + !profile.name || + (typeof profile.name === "string" && profile.name.trim() === ""); + const isCollegeMissing = + !profile.college || + (typeof profile.college === "string" && + profile.college.trim() === ""); if (isNameMissing || isCollegeMissing) { toast({ title: "Profile Incomplete", - description: "You must complete your profile (Name & College) to continue.", + description: + "You must complete your profile (Name & College) to continue.", status: "warning", duration: 3000, isClosable: true, @@ -138,6 +150,13 @@ const Layout = () => { > Events + setActiveLink("/leaderboard")} + > + Leaderboard + { {/* Same links as above */} Interview Experiences Events + Leaderboard Getting Started Team {!user ? ( @@ -330,12 +350,13 @@ const MenuItem = ({ to, children, isActive, onClick, noHoverEffect }) => { > { + const r = (rank || "").toLowerCase(); + const val = Number(rating) || 0; + + if (r.includes("legendary") || r.includes("grandmaster") || val >= 2400) { + return { + color: "#FF3333", + bg: "rgba(255, 51, 51, 0.12)", + border: "#FF3333", + }; + } + if (r.includes("master") || val >= 2100) { + return { + color: "#FF8C00", + bg: "rgba(255, 140, 0, 0.12)", + border: "#FF8C00", + }; + } + if (r.includes("candidate") || val >= 1900) { + return { + color: "#AA00AA", + bg: "rgba(170, 0, 170, 0.12)", + border: "#AA00AA", + }; + } + if (r.includes("expert") || val >= 1600) { + return { + color: "#3B82F6", + bg: "rgba(59, 130, 246, 0.12)", + border: "#3B82F6", + }; + } + if (r.includes("specialist") || val >= 1400) { + return { + color: "#03A89E", + bg: "rgba(3, 168, 158, 0.12)", + border: "#03A89E", + }; + } + if (r.includes("pupil") || val >= 1200) { + return { + color: "#22C55E", + bg: "rgba(34, 197, 94, 0.12)", + border: "#22C55E", + }; + } + return { + color: "#9CA3AF", + bg: "rgba(156, 163, 175, 0.12)", + border: "#6B7280", + }; +}; + +// Official CodeChef stars & rating color mapping +const getCodeChefStarStyle = (stars, rating) => { + const s = (stars || "").trim(); + const val = Number(rating) || 0; + + if (s.includes("7★") || val >= 2500) { + return { + color: "#DC2626", + bg: "rgba(220, 38, 38, 0.15)", + border: "#DC2626", + }; + } + if (s.includes("6★") || val >= 2200) { + return { + color: "#EA580C", + bg: "rgba(234, 88, 12, 0.15)", + border: "#EA580C", + }; + } + if (s.includes("5★") || val >= 2000) { + return { + color: "#F59E0B", + bg: "rgba(245, 158, 11, 0.15)", + border: "#F59E0B", + }; + } + if (s.includes("4★") || val >= 1800) { + return { + color: "#A855F7", + bg: "rgba(168, 85, 247, 0.15)", + border: "#A855F7", + }; + } + if (s.includes("3★") || val >= 1600) { + return { + color: "#3B82F6", + bg: "rgba(59, 130, 246, 0.15)", + border: "#3B82F6", + }; + } + if (s.includes("2★") || val >= 1400) { + return { + color: "#22C55E", + bg: "rgba(34, 197, 94, 0.15)", + border: "#22C55E", + }; + } + return { + color: "#9CA3AF", + bg: "rgba(156, 163, 175, 0.15)", + border: "#6B7280", + }; +}; + +const Leaderboard = () => { + const [platform, setPlatform] = useState("codeforces"); + const [dataPlatform, setDataPlatform] = useState("codeforces"); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [showInactive, setShowInactive] = useState(false); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [cooldownSec, setCooldownSec] = useState(0); + + // Pagination states + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(15); + + const toast = useToast(); + + // Debounce only search input typing (300ms) + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(search); + }, 300); + return () => clearTimeout(handler); + }, [search]); + + // Fetch Cooldown status + const fetchCooldown = useCallback(async () => { + try { + const res = await getLeaderboardCooldown(); + if (!res.can_refresh && res.remaining_seconds > 0) { + setCooldownSec(res.remaining_seconds); + } else { + setCooldownSec(0); + } + } catch { + // Ignore cooldown fetch failure + } + }, []); + + // Cooldown countdown timer effect + useEffect(() => { + fetchCooldown(); + }, [fetchCooldown]); + + useEffect(() => { + if (cooldownSec <= 0) return; + const interval = setInterval(() => { + setCooldownSec((prev) => { + if (prev <= 1) { + clearInterval(interval); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(interval); + }, [cooldownSec]); + + // Fetch Standings immediately whenever platform, debouncedSearch, or showInactive changes + const fetchStandings = useCallback(async (targetPlatform, targetSearch, targetInactive) => { + setLoading(true); + try { + const res = await getLeaderboard({ + platform: targetPlatform, + search: targetSearch, + showInactive: targetInactive, + }); + setData(res.results || []); + setDataPlatform(targetPlatform); + setCurrentPage(1); + } catch (err) { + toast({ + title: "Error loading leaderboard", + description: + err.response?.data?.error || "Could not fetch college standings.", + status: "error", + duration: 4000, + isClosable: true, + }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + fetchStandings(platform, debouncedSearch, showInactive); + }, [platform, debouncedSearch, showInactive, fetchStandings]); + + // Manual Refresh Handler + const handleRefresh = async () => { + if (cooldownSec > 0 || syncing) return; + setSyncing(true); + try { + const res = await refreshLeaderboard(); + toast({ + title: "Leaderboard Synced!", + description: res.message || "Updated standings from CF & CC.", + status: "success", + duration: 4000, + isClosable: true, + }); + setCooldownSec(res.cooldown_seconds || 300); + fetchStandings(platform, debouncedSearch, showInactive); + } catch (err) { + const remaining = err.response?.data?.remaining_seconds; + if (remaining) { + setCooldownSec(remaining); + } + toast({ + title: "Sync Limited", + description: + err.response?.data?.error || + "Please wait for the cooldown to finish.", + status: "warning", + duration: 4000, + isClosable: true, + }); + } finally { + setSyncing(false); + } + }; + + const formatTime = (secs) => { + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${m}:${s < 10 ? "0" : ""}${s}`; + }; + + // Check if data is synchronized with the selected platform + const isDataReady = !loading && dataPlatform === platform; + + // Pagination calculations + const totalItems = isDataReady ? data.length : 0; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + const validCurrentPage = Math.min(currentPage, totalPages); + + const paginatedData = useMemo(() => { + if (!isDataReady) return []; + const startIndex = (validCurrentPage - 1) * pageSize; + return data.slice(startIndex, startIndex + pageSize); + }, [data, validCurrentPage, pageSize, isDataReady]); + + // Generate page numbers for pagination controls + const getPageNumbers = () => { + const pages = []; + const maxVisible = 5; + let start = Math.max(1, validCurrentPage - Math.floor(maxVisible / 2)); + let end = Math.min(totalPages, start + maxVisible - 1); + + if (end - start + 1 < maxVisible) { + start = Math.max(1, end - maxVisible + 1); + } + + for (let i = start; i <= end; i++) { + pages.push(i); + } + return pages; + }; + + return ( + + + {/* Header Section */} + + + CP Leaderboard + + + Live college standings of National Institute of Technology, Durgapur + across Codeforces and CodeChef. + + + + {/* Controls Bar */} +
+
+ {/* Left: Platform Switcher */} +
+
+ + +
+
+ + {/* Right: Show Inactive Users Switch + Search Input & Sync Button */} +
+ {/* "Show inactive users" Custom Switch */} +
{ + setShowInactive((prev) => !prev); + setCurrentPage(1); + }} + > +
{ + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + setShowInactive((prev) => !prev); + setCurrentPage(1); + } + }} + > +
+
+ + Show inactive users + +
+ + {/* Search Box */} + + + + + { + setSearch(e.target.value); + setCurrentPage(1); + }} + bg="#313131" + color="#ffffff" + border="1px solid #424242" + borderRadius="lg" + _placeholder={{ color: "#B3B1AD" }} + _hover={{ borderColor: "#58CDFF" }} + _focus={{ + borderColor: "#58CDFF", + boxShadow: "0 0 0 1px #58CDFF", + }} + m="0" + /> + + + {/* Dedicated Refresh Button with Cooldown */} + 0 + ? `Next refresh available in ${formatTime(cooldownSec)}` + : "Fetch live updates from Codeforces & CodeChef" + } + hasArrow + placement="top" + > + + +
+
+
+ + {/* Standings Table: Loading Spinner shown cleanly while switching */} + {!isDataReady ? ( + + + + + Loading {platform === "codeforces" ? "Codeforces" : "CodeChef"} standings... + + + + ) : data.length === 0 ? ( + + + + No coders found + + + {showInactive + ? "Try searching with another name or handle." + : "No active coders found. Try enabling 'Show inactive users'."} + + + + ) : ( +
+
+ + + + + + + + + + + {paginatedData.map((coder) => { + const rating = + platform === "codeforces" + ? coder.codeforces_rating + : coder.codechef_rating; + + const handle = + platform === "codeforces" + ? coder.codeforces_handle + : coder.codechef_handle; + + const rankBadge = + platform === "codeforces" + ? coder.codeforces_rank + : coder.codechef_stars; + + const profileUrl = + platform === "codeforces" + ? `https://codeforces.com/profile/${coder.codeforces_handle}` + : `https://www.codechef.com/users/${coder.codechef_handle}`; + + const isUserActive = + platform === "codeforces" + ? coder.codeforces_is_active + : coder.codechef_is_active; + + // Platform specific rank styling + const rankStyle = + platform === "codeforces" + ? getCodeforcesRankStyle(coder.codeforces_rank, rating) + : getCodeChefStarStyle(coder.codechef_stars, rating); + + return ( + + {/* 1. Rank (Center Aligned) */} + + + {/* 2. Coder Info (Strictly Left Aligned) */} + + + {/* 3. Rating & Rank (Center Aligned) */} + + + {/* 4. Profile Link (Center Aligned) */} + + + ); + })} + +
+ RANK + + CODER + + RATING & RANK + + PROFILE +
+ + {coder.rank === 1 ? ( + + 🥇 #1 + + ) : coder.rank === 2 ? ( + + 🥈 #2 + + ) : coder.rank === 3 ? ( + + 🥉 #3 + + ) : ( + + #{coder.rank} + + )} + + + + + + + + {coder.name} + + {!isUserActive && ( + + Inactive + + )} + + + @{handle} + + + + + + + {rating > 0 ? rating : "Unrated"} + + {rankBadge && ( + + {rankBadge} + + )} + + + + + + + +
+
+ + {/* Pagination Controls */} + {totalItems > 0 && ( + + {/* Items Range & Page Size */} + + + Showing{" "} + + {(validCurrentPage - 1) * pageSize + 1} + {" "} + to{" "} + + {Math.min(validCurrentPage * pageSize, totalItems)} + {" "} + of{" "} + + {totalItems} + {" "} + coders + + + + + {/* Page Navigation Buttons */} + + } + aria-label="First page" + isDisabled={validCurrentPage === 1} + onClick={() => setCurrentPage(1)} + variant="ghost" + color="#B3B1AD" + _hover={{ bg: "#313131", color: "#ffffff" }} + m="0" + /> + } + aria-label="Previous page" + isDisabled={validCurrentPage === 1} + onClick={() => + setCurrentPage((prev) => Math.max(1, prev - 1)) + } + variant="ghost" + color="#B3B1AD" + _hover={{ bg: "#313131", color: "#ffffff" }} + m="0" + /> + + {getPageNumbers().map((pageNum) => ( + + ))} + + } + aria-label="Next page" + isDisabled={validCurrentPage === totalPages} + onClick={() => + setCurrentPage((prev) => + Math.min(totalPages, prev + 1) + ) + } + variant="ghost" + color="#B3B1AD" + _hover={{ bg: "#313131", color: "#ffffff" }} + m="0" + /> + } + aria-label="Last page" + isDisabled={validCurrentPage === totalPages} + onClick={() => setCurrentPage(totalPages)} + variant="ghost" + color="#B3B1AD" + _hover={{ bg: "#313131", color: "#ffffff" }} + m="0" + /> + + + )} +
+ )} + + + ); +}; + +export default Leaderboard; diff --git a/src/utils/api_routes.js b/src/utils/api_routes.js index 7b49d32..a6bec2c 100644 --- a/src/utils/api_routes.js +++ b/src/utils/api_routes.js @@ -10,4 +10,5 @@ export const API_ROUTES = { HOME: "/", GET_STARTED: "/getting_started", ROLES: "/users/roles", + LEADERBOARD: "/leaderboard", };