diff --git a/frontend/src/components/app-layout/AppLayout.tsx b/frontend/src/components/app-layout/AppLayout.tsx index f39e468..4e2c249 100644 --- a/frontend/src/components/app-layout/AppLayout.tsx +++ b/frontend/src/components/app-layout/AppLayout.tsx @@ -7,21 +7,30 @@ interface Props { children: ReactNode; } +// Navbar height constants (MUI default Toolbar heights) +const NAVBAR_HEIGHT = { xs: 56, sm: 64, md: 64 }; + export default function AppLayout({ children }: Props) { return ( - + - {/* main content */} + {/* main content - fills space between navbar and footer */} {children} diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index de9dde0..6c4cca6 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -2,15 +2,19 @@ import { Box, Card, CardContent, + Divider, + IconButton, Stack, styled, Tab, Tabs, + Tooltip, Typography, } from "@mui/material"; -import Grid from "@mui/material/Grid"; -import { useEffect, useState } from "react"; -import { fetchCodes, fetchLeaderBoard } from "../../api/api"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchLeaderBoard } from "../../api/api"; import { fetcherApiCallback } from "../../lib/hooks/useApi"; import { isExpired, toDateUtc } from "../../lib/date/utils"; import RankingsList from "./components/RankingLists"; @@ -18,16 +22,21 @@ import CodeBlock from "../../components/codeblock/CodeBlock"; import { ErrorAlert } from "../../components/alert/ErrorAlert"; import { useParams, useSearchParams } from "react-router-dom"; import Loading from "../../components/common/loading"; -import { ConstrainedContainer } from "../../components/app-layout/ConstrainedContainer"; import { SubmissionMode } from "../../lib/types/mode"; import { useAuthStore } from "../../lib/store/authStore"; import SubmissionHistorySection from "./components/submission-history/SubmissionHistorySection"; import LeaderboardSubmit from "./components/LeaderboardSubmit"; + export const CardTitle = styled(Typography)(() => ({ - fontSize: "1.5rem", + fontSize: "1.1rem", fontWeight: "bold", })); +const DEFAULT_SIDEBAR_WIDTH = 320; +const MIN_SIDEBAR_WIDTH = 200; +const MAX_SIDEBAR_WIDTH = 800; +const COLLAPSED_WIDTH = 10; + const TAB_KEYS = ["rankings", "reference", "submission"] as const; type TabKey = (typeof TAB_KEYS)[number]; @@ -54,7 +63,7 @@ function TabPanel(props: { aria-labelledby={`leaderboard-tab-${index}`} {...other} > - {value === TAB_KEYS[index] && {children}} + {value === TAB_KEYS[index] && {children}} ); } @@ -80,6 +89,57 @@ export default function Leaderboard() { const [refreshFlag, setRefreshFlag] = useState(false); const triggerRefresh = () => setRefreshFlag((f) => !f); + // Resizable sidebar state + const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH); + const [isCollapsed, setIsCollapsed] = useState(false); + const [prevWidth, setPrevWidth] = useState(DEFAULT_SIDEBAR_WIDTH); + const isResizing = useRef(false); + + const startResizing = useCallback(() => { + if (!isCollapsed) { + isResizing.current = true; + } + }, [isCollapsed]); + + const stopResizing = useCallback(() => { + isResizing.current = false; + }, []); + + const resize = useCallback((e: MouseEvent) => { + if (isResizing.current && !isCollapsed) { + const newWidth = e.clientX; + if (newWidth >= MIN_SIDEBAR_WIDTH && newWidth <= MAX_SIDEBAR_WIDTH) { + setSidebarWidth(newWidth); + } + } + }, [isCollapsed]); + + const toggleCollapse = useCallback(() => { + if (isCollapsed) { + setIsCollapsed(false); + setSidebarWidth(prevWidth); + } else { + setPrevWidth(sidebarWidth); + setIsCollapsed(true); + setSidebarWidth(COLLAPSED_WIDTH); + } + }, [isCollapsed, sidebarWidth, prevWidth]); + + // Double-click to reset to default width + const handleDoubleClick = useCallback(() => { + setIsCollapsed(false); + setSidebarWidth(DEFAULT_SIDEBAR_WIDTH); + }, []); + + useEffect(() => { + window.addEventListener("mousemove", resize); + window.addEventListener("mouseup", stopResizing); + return () => { + window.removeEventListener("mousemove", resize); + window.removeEventListener("mouseup", stopResizing); + }; + }, [resize, stopResizing]); + useEffect(() => { const current = searchParams.get("tab"); if (current !== tab) { @@ -97,55 +157,178 @@ export default function Leaderboard() { if (loading) return ; if (error) return ; - const descriptionText = (text: string) => ( - - {text} - - ); - const toDeadlineUTC = (raw: string) => `ended (${toDateUtc(raw)}) UTC`; const info_items = [ - { title: "Deadline", content: {toDeadlineUTC(data.deadline)} }, - { title: "Language", content: {data.lang} }, - { title: "GPU types", content: {data.gpu_types.join(", ")} }, + { title: "Deadline", content: toDeadlineUTC(data.deadline) }, + { title: "Language", content: data.lang }, + { title: "GPU Types", content: data.gpu_types.join(", ") }, ]; return ( - - -

{data.name}

- {/* Header info cards shown above tabs */} - - {info_items.map((info, idx) => ( - - - - {info.title} - {info.content} - - - - ))} - - - - - Description - {descriptionText(data.description)} - - - + + {/* Left Sidebar - independent scroll */} + + {/* Fixed Tabs Header */} + + + {data.name} + + + + + {/* Info Items */} + + {info_items.map((info, idx) => ( + + + {info.title} + + {info.content} + + ))} + + + - {/* Tab navigation */} - + {/* Description */} + + Description + + + {data.description} + + + + + {/* Resizable Divider with Toggle Button - LeetCode style */} + + + {/* Toggle Button */} + + + {isCollapsed ? ( + + ) : ( + + )} + + + + + + {/* Main Content Area - independent scroll */} + + {/* Fixed Tabs Header */} + setTab(v)} aria-label="Leaderboard Tabs" - variant="scrollable" - scrollButtons - allowScrollButtonsMobile + centered + sx={{ + minHeight: 36, + mt: 0, + "& .MuiTab-root": { + fontSize: "0.8rem", + minHeight: 36, + py: 0.5, + px: 1.5, + textTransform: "none", + }, + }} > @@ -153,6 +336,17 @@ export default function Leaderboard() { + {/* Scrollable Tab Content */} + + {/* Ranking Tab */} @@ -234,7 +428,8 @@ export default function Leaderboard() { )} + -
+
); } diff --git a/frontend/src/pages/leaderboard/components/RankingLists.tsx b/frontend/src/pages/leaderboard/components/RankingLists.tsx index ac2fa20..b364bf0 100644 --- a/frontend/src/pages/leaderboard/components/RankingLists.tsx +++ b/frontend/src/pages/leaderboard/components/RankingLists.tsx @@ -32,7 +32,7 @@ interface RankingsListProps { const styles: Record> = { rankingListSection: { - mt: 5, + mt: 3, }, rankingRow: { borderBottom: "1px solid #ddd", @@ -41,31 +41,34 @@ const styles: Record> = { display: "flex", justifyContent: "space-between", alignItems: "center", - mb: 1, + mb: 0.5, }, fieldLabel: { fontWeight: "bold", - fontSize: "1.1rem", + fontSize: "0.85rem", textTransform: "capitalize", }, row: { display: "flex", gap: 2, - fontSize: "0.95rem", + fontSize: "0.8rem", alignItems: "center", flexWrap: "wrap", }, name: { - fontWeight: 800, - minWidth: "90px", + fontWeight: 700, + minWidth: "80px", + fontSize: "0.8rem", }, score: { fontFamily: "monospace", - minWidth: "100px", + minWidth: "90px", + fontSize: "0.8rem", }, delta: { color: grey[600], - minWidth: "90px", + minWidth: "80px", + fontSize: "0.8rem", }, }; @@ -144,6 +147,18 @@ export default function RankingsList({ data-testid={`ranking-show-all-button-${ridx}`} onClick={() => toggleExpanded(field)} size="small" + sx={{ + backgroundColor: grey[200], + color: grey[700], + fontSize: "0.75rem", + px: 1.5, + py: 0.25, + borderRadius: 1, + textTransform: "none", + "&:hover": { + backgroundColor: grey[300], + }, + }} > {isExpanded ? "Hide" : `Show all (${items.length})`} diff --git a/frontend/src/pages/leaderboard/components/RankingTitleBadge.tsx b/frontend/src/pages/leaderboard/components/RankingTitleBadge.tsx index 5bd4b43..22e05f1 100644 --- a/frontend/src/pages/leaderboard/components/RankingTitleBadge.tsx +++ b/frontend/src/pages/leaderboard/components/RankingTitleBadge.tsx @@ -33,19 +33,19 @@ export default function RankingTitleBadge({ sx={{ display: "inline-flex", alignItems: "center", - gap: 1, - px: 2, - py: 1, + gap: 0.5, + px: 1.5, + py: 0.5, borderRadius: "200px", background: gradient, color: "white", fontWeight: "bold", - fontSize: "1rem", + fontSize: "0.8rem", boxShadow: "0 2px 6px rgba(0,0,0,0.1)", width: "fit-content", }} > - + {name}