Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions UI/src/api/soroban-security-portal/models/leaderboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export enum TimePeriod {
AllTime = 'All-Time',
Year = 'This Year',
Month = 'This Month',
Week = 'This Week',
}

export enum LeaderboardCategory {
Overall = 'Overall',
Reports = 'Reports',
Vulnerabilities = 'Vulnerabilities',
Community = 'Community',
}

export interface LeaderboardEntry {
rank: number;
prevRank?: number; // For position change indicators
userId: string;
username: string;
avatarUrl?: string;
reputation: number;
badgeCount: number;
isCurrentUser?: boolean;
}

export interface LeaderboardFilters {
period: TimePeriod;
category: LeaderboardCategory;
}
244 changes: 244 additions & 0 deletions UI/src/features/pages/regular/leaderboard/leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { FC, useEffect, useState } from 'react';
import {
Box,
Typography,
Avatar,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Tabs,
Tab,
ToggleButton,
ToggleButtonGroup,
Stack,
CircularProgress,
Tooltip,
} from '@mui/material';
import {
EmojiEvents as TrophyIcon,
TrendingUp as UpIcon,
TrendingDown as DownIcon,
HorizontalRule as NeutralIcon,
MilitaryTech as BadgeIcon
} from '@mui/icons-material';
import { useTheme } from '../../../../contexts/ThemeContext';
import { AccentColors } from '../../../../theme';
import {
TimePeriod,
LeaderboardCategory,
LeaderboardEntry
} from '../../../../api/soroban-security-portal/models/leaderboard';
import ReactGA from 'react-ga4';
import { useAuth } from 'react-oidc-context';

export const Leaderboard: FC = () => {
const { themeMode } = useTheme();
const auth = useAuth();
const [period, setPeriod] = useState<TimePeriod>(TimePeriod.AllTime);
const [category, setCategory] = useState<LeaderboardCategory>(LeaderboardCategory.Overall);
const [isLoading, setIsLoading] = useState(true);
const [leaderboardData, setLeaderboardData] = useState<LeaderboardEntry[]>([]);

useEffect(() => {
ReactGA.send({ hitType: "pageview", page: "/leaderboard", title: "Leaderboard Page" });
}, []);

// Simulate data fetching with caching
useEffect(() => {
const cacheKey = `leaderboard_${period}_${category}`;
const cachedData = localStorage.getItem(cacheKey);
const cacheTime = localStorage.getItem(`${cacheKey}_time`);
const TTL = 5 * 60 * 1000; // 5 minutes cache

const fetchLeaderboard = async () => {
setIsLoading(true);
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 800));

// Mock data generation
const mockData: LeaderboardEntry[] = Array.from({ length: 100 }, (_, i) => ({
rank: i + 1,
prevRank: i + 1 + (Math.random() > 0.7 ? (Math.random() > 0.5 ? 1 : -1) : 0),
userId: `user-${i}`,
username: i === 5 ? auth.user?.profile.name || 'Current User' : `Contributor_${i + 1}`,
reputation: Math.floor(10000 / (i + 1)) + Math.floor(Math.random() * 100),
badgeCount: Math.floor(20 / (i + 1)) + (i < 5 ? 5 : 0),
isCurrentUser: i === 5 && !!auth.user,
avatarUrl: `https://i.pravatar.cc/150?u=user-${i}`
}));

setLeaderboardData(mockData);
localStorage.setItem(cacheKey, JSON.stringify(mockData));
localStorage.setItem(`${cacheKey}_time`, Date.now().toString());
setIsLoading(false);
};

if (cachedData && cacheTime && (Date.now() - parseInt(cacheTime) < TTL)) {
setLeaderboardData(JSON.parse(cachedData));
setIsLoading(false);
} else {
fetchLeaderboard();
}
}, [period, category, auth.user]);

const handlePeriodChange = (
_event: React.MouseEvent<HTMLElement>,
newPeriod: TimePeriod | null,
) => {
if (newPeriod !== null) {
setPeriod(newPeriod);
}
};

const handleCategoryChange = (_event: React.SyntheticEvent, newCategory: LeaderboardCategory) => {
setCategory(newCategory);
};

const getRankIcon = (rank: number) => {
if (rank === 1) return <TrophyIcon sx={{ color: '#FFD700' }} />;
if (rank === 2) return <TrophyIcon sx={{ color: '#C0C0C0' }} />;
if (rank === 3) return <TrophyIcon sx={{ color: '#CD7F32' }} />;
return null;
};

const getTrendIcon = (rank: number, prevRank?: number) => {
if (!prevRank || rank === prevRank) return <NeutralIcon sx={{ color: 'text.disabled', fontSize: 16 }} />;
if (rank < prevRank) return <UpIcon sx={{ color: 'success.main', fontSize: 16 }} />;
return <DownIcon sx={{ color: 'error.main', fontSize: 16 }} />;
};

return (
<Box sx={{ p: { xs: 2, md: 4 } }}>
<Typography variant="h3" sx={{ fontWeight: 700, mb: 4, color: themeMode === 'light' ? '#1A1A1A' : '#F2F2F2' }}>
LEADERBOARD
</Typography>

<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
<Tabs
value={category}
onChange={handleCategoryChange}
textColor="primary"
indicatorColor="primary"
sx={{
'& .MuiTabs-indicator': { backgroundColor: AccentColors.navigationActive },
'& .MuiTab-root': {
fontSize: '1.1rem',
textTransform: 'none',
fontWeight: 600,
color: 'text.secondary',
'&.Mui-selected': { color: AccentColors.navigationActive }
}
}}
>
{Object.values(LeaderboardCategory).map((cat) => (
<Tab key={cat} label={cat} value={cat} />
))}
</Tabs>

<ToggleButtonGroup
value={period}
exclusive
onChange={handlePeriodChange}
aria-label="time period"
size="small"
>
{Object.values(TimePeriod).map((p) => (
<ToggleButton
key={p}
value={p}
sx={{
textTransform: 'none',
px: 2,
'&.Mui-selected': {
backgroundColor: AccentColors.navigationActive,
color: '#000',
'&:hover': { backgroundColor: '#e6c245' }
}
}}
>
{p}
</ToggleButton>
))}
</ToggleButtonGroup>
</Stack>

<TableContainer component={Paper} sx={{ borderRadius: 4, boxShadow: '0 4px 20px rgba(0,0,0,0.1)', overflow: 'auto', maxHeight: 'calc(100vh - 300px)' }}>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 800, fontSize: '0.9rem', width: 80 }}>RANK</TableCell>
<TableCell sx={{ fontWeight: 800, fontSize: '0.9rem' }}>CONTRIBUTOR</TableCell>
<TableCell align="right" sx={{ fontWeight: 800, fontSize: '0.9rem' }}>REPUTATION</TableCell>
<TableCell align="right" sx={{ fontWeight: 800, fontSize: '0.9rem' }}>BADGES</TableCell>
<TableCell align="center" sx={{ fontWeight: 800, fontSize: '0.9rem', width: 100 }}>TREND</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 10 }}>
<CircularProgress size={40} sx={{ color: AccentColors.loadingIndicator }} />
</TableCell>
</TableRow>
) : (
leaderboardData.map((row) => (
<TableRow
key={row.userId}
sx={{
backgroundColor: row.isCurrentUser ? 'rgba(255, 216, 77, 0.15)' : 'inherit',
'&:hover': { backgroundColor: themeMode === 'light' ? 'rgba(0,0,0,0.02)' : 'rgba(255,255,255,0.02)' },
transition: 'background-color 0.2s'
}}
>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="body1" fontWeight={row.rank <= 3 ? 800 : 500}>
#{row.rank}
</Typography>
{getRankIcon(row.rank)}
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={2}>
<Avatar src={row.avatarUrl} sx={{ width: 40, height: 40, border: row.rank <= 3 ? `2px solid ${row.rank === 1 ? '#FFD700' : row.rank === 2 ? '#C0C0C0' : '#CD7F32'}` : 'none' }} />
<Typography variant="body1" fontWeight={row.isCurrentUser ? 700 : 500}>
{row.username}
{row.isCurrentUser && (
<Typography component="span" variant="caption" sx={{ ml: 1, color: AccentColors.navigationActive, fontWeight: 800 }}>
(YOU)
</Typography>
)}
</Typography>
</Stack>
</TableCell>
<TableCell align="right">
<Typography variant="body1" fontWeight={700} color="primary">
{row.reputation.toLocaleString()}
</Typography>
</TableCell>
<TableCell align="right">
<Stack direction="row" alignItems="center" justifyContent="flex-end" spacing={0.5}>
<Typography variant="body1" fontWeight={600}>
{row.badgeCount}
</Typography>
<BadgeIcon sx={{ fontSize: 18, color: AccentColors.navigationActive }} />
</Stack>
</TableCell>
<TableCell align="center">
<Tooltip title={row.prevRank ? `Previous rank: #${row.prevRank}` : 'Same position'} arrow>
<Box>{getTrendIcon(row.rank, row.prevRank)}</Box>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
9 changes: 6 additions & 3 deletions UI/src/features/pages/regular/main-window/main-window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ProtocolDetails } from '../protocol-details/protocol-details';
import { ReportDetails } from '../report-details/report-details';
import { AuditorDetails } from '../auditor-details/auditor-details';
import { CompanyDetails } from '../company-details/company-details';
import { Leaderboard } from '../leaderboard/leaderboard';
import { useTheme } from '../../../../contexts/ThemeContext';
import { useToolbarAvatar } from '../../../../hooks/useToolbarAvatar';
import { getUserInitials } from '../../../../utils/user-utils';
Expand Down Expand Up @@ -57,7 +58,7 @@ export const MainWindow: FC = () => {

// mobile drawer
const [mobileOpen, setMobileOpen] = useState(false);
const toggleMobile = () => setMobileOpen(prev => !prev);
const toggleMobile = () => setMobileOpen(prev => !prev);

// avatar state from shared hook
const { avatarUrl, avatarLoading, avatarError, handleAvatarLoad, handleAvatarError } = useToolbarAvatar();
Expand Down Expand Up @@ -85,6 +86,7 @@ export const MainWindow: FC = () => {
{ label: 'Home', path: '/' },
{ label: 'Reports', path: '/reports' },
{ label: 'Vulnerabilities', path: '/vulnerabilities' },
{ label: 'Leaderboard', path: '/leaderboard' },
{ label: 'About', path: '/about' },
];
const isAdminUser = auth.isAuthenticated && auth.user && (auth.user?.profile.role === Role.Admin || auth.user?.profile.role === Role.Moderator);
Expand Down Expand Up @@ -240,14 +242,14 @@ export const MainWindow: FC = () => {
<MenuItem onClick={() => navigate('/profile')}>My Profile</MenuItem>
<MenuItem onClick={handleUserMenuItemLogoutClick}>Log out</MenuItem>
</Menu>
{!auth.isLoading && <BookmarkMenu bookmarks={bookmarks}/> }
{!auth.isLoading && <BookmarkMenu bookmarks={bookmarks} />}
</>
) : (
<Button
color="primary"
variant="contained"
onClick={() => navigate('/login')}
sx={{ ml: 2, textTransform: 'uppercase', px: 3, py: 1, display: { xs: 'none', md: 'inline-flex' } }}
sx={{ ml: 2, textTransform: 'uppercase', px: 3, py: 1, display: { xs: 'none', md: 'inline-flex' } }}
>
Log In
</Button>
Expand Down Expand Up @@ -336,6 +338,7 @@ export const MainWindow: FC = () => {
<Route path={`${environment.basePath}/about`} element={<About />} />
<Route path={`${environment.basePath}/profile`} element={<Profile />} />
<Route path={`${environment.basePath}/profile/edit`} element={<EditProfile />} />
<Route path={`${environment.basePath}/leaderboard`} element={<Leaderboard />} />
<Route path={`${environment.basePath}/vulnerability/:id`} element={<VulnerabilityDetails />} />
<Route path={`${environment.basePath}/protocol/:id`} element={<ProtocolDetails />} />
<Route path={`${environment.basePath}/report/:id`} element={<ReportDetails />} />
Expand Down