Skip to content
Draft
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
6 changes: 4 additions & 2 deletions index.html

Large diffs are not rendered by default.

16 changes: 13 additions & 3 deletions src/API/Index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import axios, { AxiosError } from 'axios';
import refreshToken from '../services/retryToken';

const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
});

export const protectedapi = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
});

export default api;

protectedapi.interceptors.response.use(
(response) => {
return response;
Expand All @@ -21,9 +25,15 @@ protectedapi.interceptors.response.use(
try {
await refreshToken();
return protectedapi(originalRequest);
} catch (error) {
// window.location.href = '/signin';
return Promise.reject(error);
} catch (refreshError) {
// Refresh failed - clear cookies and redirect to signin
document.cookie = 'access-token=; max-age=0; path=/;';
document.cookie = 'refresh-token=; max-age=0; path=/;';
document.cookie = 'session-token=; max-age=0; path=/;';
document.cookie = 'id=; max-age=0; path=/;';

window.location.href = '/signin';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
Expand Down
86 changes: 82 additions & 4 deletions src/components/Pages/Home/HomeNavbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Link as ReactLink } from 'react-router-dom';
import { Link as ReactLink, useLocation, useNavigate } from 'react-router-dom';
import darklogo from '../../../assets/images/logo-dark.26900637.svg';
import lightlogo from '../../../assets/images/logo-light.5034df26.svg';
import { usethemeUtils } from '../../../context/ThemeWrapper';
Expand All @@ -7,22 +7,100 @@ import { useAuthSlice } from '../../../store/authslice/auth';
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
import DarkModeIcon from '@mui/icons-material/DarkModeOutlined';
import Profile from '../../UI/Profile';
import { useCallback } from 'react';
import ProblemAutocomplete from '../Problems/ProblemAutocomplete';
import useProblemsAutocomplete from '../../../hooks/useProblemsAutocomplete';
import { MappedSearchResult } from '../../../utils/elasticsearchMapper';
import { useProblemsSearchSlice } from '../../../store/problemsSearchSlice';

export default function HomeNavbar() {
const { colorMode, toggleColorMode } = usethemeUtils();
const isLogedIn = useAuthSlice((state) => state.isLogedIn);
const location = useLocation();
const navigate = useNavigate();

// Use Zustand store for search state (synced across pages)
const searchQuery = useProblemsSearchSlice((state) => state.searchQuery);
const setSearchQuery = useProblemsSearchSlice((state) => state.setSearchQuery);

const { topResults, isSearching } = useProblemsAutocomplete(searchQuery);

// Handle autocomplete selection - navigate to problems list with search query maintained
const handleAutocompleteSelect = useCallback(
(_: MappedSearchResult) => {
// Keep the search query in store and navigate to problems list
navigate('/problems');
},
[navigate]
);

const handleSearchChange = useCallback((query: string) => {
setSearchQuery(query);
}, [setSearchQuery]);

return (
<nav className='tw-container-lg tw-mx-auto tw-flex tw-justify-around tw-p-2 tw-bottom-2 tw-border-b-[#ffffff24]'>
<div className='tw-flex tw-items-center tw-gap-2'>
<nav className='tw-container-lg tw-mx-auto tw-flex tw-items-center tw-justify-between tw-p-2 tw-bottom-2 tw-border-b-[#ffffff24] tw-gap-4'>
{/* Logo */}
<div className='tw-flex-shrink-0'>
<img
src={colorMode === 'light' ? darklogo : lightlogo}
width={100}
height={80}
className='tw-object-contain'
></img>
</div>
<ul className='tw-list-none tw-flex tw-justify-between tw-items-center'>

{/* Center Content - Autocomplete or Navigation */}
<div className='tw-flex-1 tw-flex tw-items-center tw-justify-center'>
{/* Problem Autocomplete - Show only on home page */}
{location.pathname === '/' && isLogedIn && (
<div className='tw-w-full tw-max-w-2xl'>
<ProblemAutocomplete
topResults={topResults}
isSearching={isSearching}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSelect={handleAutocompleteSelect}
maxWidth='100%'
/>
</div>
)}

{/* Navigation Links - Show on other pages */}
{(location.pathname === '/leaderboard' || location.pathname === '/problems') && (
<Link
className={`tw-py-2 tw-px-4 ${colorMode === 'dark' ? 'tw-text-white' : ''}`}
underline='hover'
component={ReactLink}
to='/'
>
Problems
</Link>
)}
{location.pathname !== '/' && location.pathname !== '/leaderboard' && location.pathname !== '/problems' && (
<div className='tw-flex tw-gap-4'>
<Link
className={`tw-py-2 tw-px-4 ${colorMode === 'dark' ? 'tw-text-white' : ''}`}
underline='hover'
component={ReactLink}
to='/'
>
Problems
</Link>
<Link
className={`tw-py-2 tw-px-4 ${colorMode === 'dark' ? 'tw-text-white' : ''}`}
underline='hover'
component={ReactLink}
to='/leaderboard'
>
Leaderboard
</Link>
</div>
)}
</div>

{/* Right Side - Auth & Theme */}
<ul className='tw-list-none tw-flex tw-items-center tw-gap-2 tw-flex-shrink-0'>
<li className='tw-flex tw-justify-center tw-items-center'>
{!isLogedIn ? (
<div className='tw-flex tw-justify-between tw-items-center'>
Expand Down
71 changes: 71 additions & 0 deletions src/components/Pages/LeaderBoard/LeaderBoard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useEffect } from 'react';
import { Box, Container, useTheme } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import HomeNavbar from '../Home/HomeNavbar';
import UserStats from './UserStats';
import LeaderBoardFilters from './LeaderBoardFilters';
import LeaderBoardTable from './LeaderBoardTable';
import getLeaderBoardPagination from '../../../services/getLeaderBoardPagination';
import { useUserSlice } from '../../../store/user';
import useLeaderboardStore from '../../../store/leaderboardSlice';
import LeaderBoardTablePagination from './LeaderBoardTablePagination';

export default function LeaderBoard() {
const theme = useTheme();
const currentUser = useUserSlice((state) => state.user);
const setLeaderboardUsers = useLeaderboardStore((state) => state.setLeaderboardUsers);
const setLoading = useLeaderboardStore((state) => state.setLoading);
const setError = useLeaderboardStore((state) => state.setError);
const setCurrentUser = useLeaderboardStore((state) => state.setCurrentUser);
const setPaginationState = useLeaderboardStore((state) => state.setPaginationState);
const currentPage = useLeaderboardStore((state) => state.pagination.currentPage);
const pageSize = useLeaderboardStore((state) => state.pagination.pageSize);

const { data, isLoading, isError, error } = useQuery({
queryKey: ['leaderboard', currentPage, pageSize],
queryFn: () => getLeaderBoardPagination(currentPage, pageSize),
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5,
});

useEffect(() => {
if (data?.data && Array.isArray(data.data.users) && data.data.pagination) {
setLeaderboardUsers(data.data.users || [], new Date());
setPaginationState({
totalPages: data.data.pagination.totalPages ?? 1,
totalUsers: data.data.pagination.totalUsers ?? 0,
hasNextPage: data.data.pagination.hasNextPage ?? false,
hasPrevPage: data.data.pagination.hasPrevPage ?? false,
});
}
}, [data, setLeaderboardUsers, setPaginationState]);

useEffect(() => {
setLoading(isLoading);
}, [isLoading, setLoading]);

useEffect(() => {
if (isError) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load leaderboard';
setError(errorMessage);
}
}, [isError, error, setError]);

useEffect(() => {
if (currentUser?._id) {
setCurrentUser(currentUser._id);
}
}, [currentUser, setCurrentUser]);

return (
<Box sx={{ minHeight: '100vh', backgroundColor: theme.palette.background.default }}>
<HomeNavbar />
<Container maxWidth='xl' sx={{ py: 3, display: 'flex', flexDirection: 'column', gap: 0 }}>
<UserStats />
<LeaderBoardFilters />
<LeaderBoardTable />
<LeaderBoardTablePagination />
</Container>
</Box>
);
}
165 changes: 165 additions & 0 deletions src/components/Pages/LeaderBoard/LeaderBoardFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { Box, TextField, InputAdornment, useTheme, IconButton, Tooltip, Stack, CircularProgress, MenuItem, Select, FormControl } from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { useLeaderboardStore } from '../../../store/leaderboardSlice';
import { usethemeUtils } from '../../../context/ThemeWrapper';
import useDebounce from '../../../hooks/useDebounce';
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getLeaderboardFilters } from '../../../services/getLeaderboardFilters';

export default function LeaderBoardFilters() {
const theme = useTheme();
const { colorMode } = usethemeUtils();
const setSearchQuery = useLeaderboardStore((state) => state.setSearchQuery);
const searchQuery = useLeaderboardStore((state) => state.filters.searchQuery);
const timePeriod = useLeaderboardStore((state) => state.filters.timePeriod);
const setTimePeriod = useLeaderboardStore((state) => state.setTimePeriod);
const showRankIndicators = useLeaderboardStore((state) => state.ui.showRankIndicators);
const toggleShowRankIndicators = useLeaderboardStore((state) => state.toggleShowRankIndicators);
const setLeaderboardUsers = useLeaderboardStore((state) => state.setLeaderboardUsers);
const setPaginationState = useLeaderboardStore((state) => state.setPaginationState);
const setLoading = useLeaderboardStore((state) => state.setLoading);
const setError = useLeaderboardStore((state) => state.setError);

// Debounce search input with 500ms delay
const debouncedSearch = useDebounce(searchQuery, 500);

// Use React Query for filters with caching
const { isLoading, error, refetch } = useQuery({
queryKey: ['leaderboard-filters', debouncedSearch.trim(), timePeriod],
queryFn: async () => {
const response = await getLeaderboardFilters({
userName: debouncedSearch.trim() || undefined,
period: timePeriod === 'all' ? undefined : timePeriod,
page: 1,
limit: 50,
});

// Validate response structure
if (!response || !Array.isArray(response.users)) {
throw new Error('Invalid response structure from API');
}

// Update store with API results
setLeaderboardUsers(response.users, new Date());

// Update pagination if available
if (response.pagination) {
setPaginationState({
totalPages: response.pagination.totalPages ?? 1,
totalUsers: response.pagination.totalUsers ?? 0,
hasNextPage: response.pagination.hasNextPage ?? false,
hasPrevPage: response.pagination.hasPrevPage ?? false,
});
}

return response;
},
staleTime: 1000 * 60 * 5, // 5 minutes cache
gcTime: 1000 * 60 * 10, // 10 minutes garbage collection
enabled: true, // Always enabled so API fires for all time option too
refetchOnWindowFocus: false,
});

// Force refetch when switching to 'all' period
useEffect(() => {
if (timePeriod === 'all') {
refetch();
}
}, [timePeriod, refetch]);

// Sync store loading and error states with React Query
useEffect(() => {
setLoading(isLoading);
}, [isLoading, setLoading]);

useEffect(() => {
if (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch filtered leaderboard';
setError(errorMessage);
} else {
setError(null);
}
}, [error, setError]);

return (
<Box sx={{ px: 2, py: 2 }}>
<Stack direction='row' spacing={2} alignItems='center'>
<Box sx={{ position: 'relative', flex: 1, maxWidth: '400px' }}>
<TextField
placeholder='Search leaderboard...'
variant='outlined'
size='small'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={isLoading}
sx={{
width: '100%',
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
backgroundColor: colorMode === 'dark' ? theme.palette.background.paper : '#f9f9f9',
'&:hover': {
backgroundColor: theme.palette.background.paper,
},
},
}}
InputProps={{
startAdornment: (
<InputAdornment position='start'>
{isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : (
<SearchIcon sx={{ color: theme.palette.text.secondary, fontSize: 24 }} />
)}
</InputAdornment>
),
}}
/>
</Box>

<FormControl size='small' sx={{ minWidth: 120 }}>
<Select
value={timePeriod}
onChange={(e) => setTimePeriod(e.target.value as 'today' | 'week' | 'month' | 'all')}
disabled={isLoading}
sx={{
borderRadius: '8px',
backgroundColor: colorMode === 'dark' ? theme.palette.background.paper : '#f9f9f9',
'&:hover': {
backgroundColor: theme.palette.background.paper,
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.divider,
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: theme.palette.primary.main,
},
}}
>
<MenuItem value='all'>All Time</MenuItem>
<MenuItem value='today'>Today</MenuItem>
<MenuItem value='week'>This Week</MenuItem>
<MenuItem value='month'>This Month</MenuItem>
</Select>
</FormControl>

<Tooltip title={showRankIndicators ? 'Hide rank indicators' : 'Show rank indicators'}>
<IconButton
size='small'
onClick={() => toggleShowRankIndicators()}
sx={{
color: showRankIndicators ? theme.palette.primary.main : theme.palette.text.secondary,
'&:hover': {
backgroundColor: colorMode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
},
}}
>
{showRankIndicators ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</Tooltip>
</Stack>
</Box>
);
}
Loading