diff --git a/public/assets/Sample_Profile_1.jpg b/public/assets/Sample_Profile_1.jpg new file mode 100644 index 0000000000..332e4d6ef1 Binary files /dev/null and b/public/assets/Sample_Profile_1.jpg differ diff --git a/public/assets/Sample_Profile_2.jpg b/public/assets/Sample_Profile_2.jpg new file mode 100644 index 0000000000..9d33c12252 Binary files /dev/null and b/public/assets/Sample_Profile_2.jpg differ diff --git a/public/assets/Sample_Profile_3.jpg b/public/assets/Sample_Profile_3.jpg new file mode 100644 index 0000000000..b2784bedcd Binary files /dev/null and b/public/assets/Sample_Profile_3.jpg differ diff --git a/public/assets/Sample_Profile_4.jpg b/public/assets/Sample_Profile_4.jpg new file mode 100644 index 0000000000..eb9f8ff85f Binary files /dev/null and b/public/assets/Sample_Profile_4.jpg differ diff --git a/public/assets/Sample_Profile_5.jpg b/public/assets/Sample_Profile_5.jpg new file mode 100644 index 0000000000..a6c974c3f9 Binary files /dev/null and b/public/assets/Sample_Profile_5.jpg differ diff --git a/public/assets/Sample_Profile_6.jpg b/public/assets/Sample_Profile_6.jpg new file mode 100644 index 0000000000..3a2424c3d9 Binary files /dev/null and b/public/assets/Sample_Profile_6.jpg differ diff --git a/public/assets/Sample_Profile_7.jpg b/public/assets/Sample_Profile_7.jpg new file mode 100644 index 0000000000..d6dc032898 Binary files /dev/null and b/public/assets/Sample_Profile_7.jpg differ diff --git a/src/assets/default-avatar.jpg b/src/assets/default-avatar.jpg new file mode 100644 index 0000000000..70fcf1906a Binary files /dev/null and b/src/assets/default-avatar.jpg differ diff --git a/src/assets/leaderboard_background.jpg b/src/assets/leaderboard_background.jpg new file mode 100644 index 0000000000..b9816388c0 Binary files /dev/null and b/src/assets/leaderboard_background.jpg differ diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 9f91a27e1f..fb56e1d6a2 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -2,6 +2,7 @@ import { Chapter, Language, type SourceError, type Value, Variant } from 'js-sla import type { AchievementState } from '../../features/achievement/AchievementTypes'; import type { DashboardState } from '../../features/dashboard/DashboardTypes'; +import type { LeaderboardState } from '../../features/leaderboard/LeaderboardTypes'; import type { PlaygroundState } from '../../features/playground/PlaygroundTypes'; import { PlaybackStatus, RecordingStatus } from '../../features/sourceRecorder/SourceRecorderTypes'; import type { StoriesEnvState, StoriesState } from '../../features/stories/StoriesTypes'; @@ -26,6 +27,7 @@ import type { VscodeState as VscodeState } from './types/VscodeTypes'; export type OverallState = { readonly router: RouterState; readonly achievement: AchievementState; + readonly leaderboard: LeaderboardState; readonly playground: PlaygroundState; readonly session: SessionState; readonly stories: StoriesState; @@ -345,6 +347,16 @@ export const defaultAchievement: AchievementState = { assessmentOverviews: [] }; +export const defaultLeaderboard: LeaderboardState = { + userXp: [], + paginatedUserXp: { rows: [], userCount: 0 }, + contestScore: [], + contestPopularVote: [], + code: '', + contests: [], + initialRun: {} +}; + const getDefaultLanguageConfig = (): SALanguage => { const languageConfig = ALL_LANGUAGES.find( sublang => @@ -604,6 +616,7 @@ export const defaultVscode: VscodeState = { export const defaultState: OverallState = { router: defaultRouter, achievement: defaultAchievement, + leaderboard: defaultLeaderboard, dashboard: defaultDashboard, playground: defaultPlayground, session: defaultSession, diff --git a/src/commons/application/reducers/RootReducer.ts b/src/commons/application/reducers/RootReducer.ts index acf3840940..4c89cf6377 100644 --- a/src/commons/application/reducers/RootReducer.ts +++ b/src/commons/application/reducers/RootReducer.ts @@ -1,9 +1,10 @@ import { combineReducers, Reducer } from '@reduxjs/toolkit'; -import { FeatureFlagsReducer as featureFlags } from 'src/commons/featureFlags'; import { SourceActionType } from 'src/commons/utils/ActionsHelper'; +import { FeatureFlagsReducer as featureFlags } from '../../..//commons/featureFlags'; import { AchievementReducer as achievement } from '../../../features/achievement/AchievementReducer'; import { DashboardReducer as dashboard } from '../../../features/dashboard/DashboardReducer'; +import { LeaderboardReducer as leaderboard } from '../../../features/leaderboard/LeaderboardReducer'; import { PlaygroundReducer as playground } from '../../../features/playground/PlaygroundReducer'; import { StoriesReducer as stories } from '../../../features/stories/StoriesReducer'; import { FileSystemReducer as fileSystem } from '../../fileSystem/FileSystemReducer'; @@ -17,6 +18,7 @@ import { VscodeReducer as vscode } from './VscodeReducer'; const rootReducer: Reducer = combineReducers({ router, achievement, + leaderboard, dashboard, playground, session, diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 20d4bb4402..401466989f 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -39,6 +39,10 @@ export type SessionState = { readonly enableAchievements?: boolean; readonly enableSourcecast?: boolean; readonly enableStories?: boolean; + readonly enableOverallLeaderboard?: boolean; + readonly enableContestLeaderboard?: boolean; + readonly topLeaderboardDisplay?: number; + readonly topContestLeaderboardDisplay?: number; readonly sourceChapter?: Chapter; readonly sourceVariant?: Variant; readonly moduleHelpText?: string; @@ -105,6 +109,10 @@ export type CourseConfiguration = { enableAchievements: boolean; enableSourcecast: boolean; enableStories: boolean; + enableOverallLeaderboard: boolean; + enableContestLeaderboard: boolean; + topLeaderboardDisplay: number; + topContestLeaderboardDisplay: number; sourceChapter: Chapter; sourceVariant: Variant; moduleHelpText: string; diff --git a/src/commons/assessment/Assessment.tsx b/src/commons/assessment/Assessment.tsx index 2fbfb5526c..a86dbeaf21 100644 --- a/src/commons/assessment/Assessment.tsx +++ b/src/commons/assessment/Assessment.tsx @@ -40,7 +40,7 @@ import NotificationBadge from '../notificationBadge/NotificationBadge'; import { filterNotificationsByAssessment } from '../notificationBadge/NotificationBadgeHelper'; import Constants from '../utils/Constants'; import { beforeNow, getPrettyDate, getPrettyDateAfterHours } from '../utils/DateHelper'; -import { useResponsive, useSession } from '../utils/Hooks'; +import { useResponsive, useSession, useTypedSelector } from '../utils/Hooks'; import { assessmentTypeLink, convertParamToInt } from '../utils/ParamParseHelper'; import AssessmentNotFound from './AssessmentNotFound'; import { @@ -262,6 +262,8 @@ const Assessment: React.FC = () => { [assessmentConfigToLoad.type, assessmentOverviewsUnfiltered] ); + const fromLeaderboard: boolean = useTypedSelector(store => store.leaderboard.code) ? true : false; + // If assessmentId or questionId is defined but not numeric, redirect back to the Assessment overviews page if ( (params.assessmentId && !params.assessmentId?.match(numberRegExp)) || @@ -290,7 +292,8 @@ const Assessment: React.FC = () => { canSave: role !== Role.Student || (overview.status !== AssessmentStatuses.submitted && !beforeNow(overview.closeAt)), - assessmentConfiguration: assessmentConfigToLoad + assessmentConfiguration: assessmentConfigToLoad, + fromContestLeaderboard: fromLeaderboard }; return ; } diff --git a/src/commons/assessment/AssessmentTypes.ts b/src/commons/assessment/AssessmentTypes.ts index 2bd726f9b3..bbd7b13644 100644 --- a/src/commons/assessment/AssessmentTypes.ts +++ b/src/commons/assessment/AssessmentTypes.ts @@ -194,6 +194,7 @@ export type ContestEntry = { score?: number; final_score?: number; student_name?: string; + rank?: number; }; export type ContestEntryCodeAnswer = { diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index 20138018e5..3889241818 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -19,6 +19,7 @@ import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router'; import { showSimpleConfirmDialog } from 'src/commons/utils/DialogHelper'; import { onClickProgress } from 'src/features/assessments/AssessmentUtils'; +import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; import Messages, { sendToWebview } from 'src/features/vscode/messages'; import { mobileOnlyTabIds } from 'src/pages/playground/PlaygroundTabs'; @@ -85,6 +86,7 @@ export type AssessmentWorkspaceProps = { notAttempted: boolean; canSave: boolean; assessmentConfiguration: AssessmentConfiguration; + fromContestLeaderboard: boolean; }; const workspaceLocation: WorkspaceLocation = 'assessment'; @@ -186,6 +188,17 @@ const AssessmentWorkspace: React.FC = props => { }; }, [dispatch]); + const code = useTypedSelector(store => store.leaderboard.code); + const initialRunCompleted = useTypedSelector(store => store.leaderboard.initialRun); + const votingId = props.assessmentId; + + useEffect(() => { + if (initialRunCompleted[votingId] && props.fromContestLeaderboard && code != '') { + dispatch(WorkspaceActions.updateEditorValue(workspaceLocation, 0, code)); + dispatch(LeaderboardActions.clearCode()); + } + }, [dispatch]); + useEffect(() => { if (assessmentOverview && assessmentOverview.maxTeamSize > 1) { handleTeamOverviewFetch(props.assessmentId); @@ -216,7 +229,6 @@ const AssessmentWorkspace: React.FC = props => { if (!assessment) { return; } - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /** @@ -225,7 +237,10 @@ const AssessmentWorkspace: React.FC = props => { */ useEffect(() => { checkWorkspaceReset(); - }); + if (assessment != undefined && question.type == 'voting') { + dispatch(LeaderboardActions.setWorkspaceInitialRun(votingId)); + } + }, [dispatch, assessment]); /** * Handles toggling enabling and disabling token counter depending on assessment properties @@ -367,6 +382,7 @@ const AssessmentWorkspace: React.FC = props => { case QuestionTypes.voting: const votingQuestionData: IContestVotingQuestion = question; options.programPrependValue = votingQuestionData.prepend; + if (props.fromContestLeaderboard) options.editorValue = code; options.programPostpendValue = votingQuestionData.postpend; break; case QuestionTypes.mcq: diff --git a/src/commons/assessmentWorkspace/__tests__/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/__tests__/AssessmentWorkspace.tsx index 311db2ed35..312d2dfeba 100644 --- a/src/commons/assessmentWorkspace/__tests__/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/__tests__/AssessmentWorkspace.tsx @@ -27,6 +27,7 @@ const defaultProps = assertType()({ hoursBeforeEarlyXpDecay: 48, earlySubmissionXp: 200 }, + fromContestLeaderboard: false, questionId: 0 }); diff --git a/src/commons/dropdown/DropdownCreateCourse.tsx b/src/commons/dropdown/DropdownCreateCourse.tsx index 2d37ce7eb9..668e595a25 100644 --- a/src/commons/dropdown/DropdownCreateCourse.tsx +++ b/src/commons/dropdown/DropdownCreateCourse.tsx @@ -40,6 +40,10 @@ const DropdownCreateCourse: React.FC = props => { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableOverallLeaderboard: true, + enableContestLeaderboard: true, + topLeaderboardDisplay: 100, + topContestLeaderboardDisplay: 10, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: '' @@ -197,6 +201,28 @@ const DropdownCreateCourse: React.FC = props => { }) } /> + + setCourseConfig({ + ...courseConfig, + enableOverallLeaderboard: (e.target as HTMLInputElement).checked + }) + } + /> + + setCourseConfig({ + ...courseConfig, + enableContestLeaderboard: (e.target as HTMLInputElement).checked + }) + } + />
= props => { />
+
+ + + setCourseConfig({ + ...courseConfig, + topLeaderboardDisplay: Number(e.target.value) + }) + } + /> + +
+
+ + + setCourseConfig({ + ...courseConfig, + topContestLeaderboardDisplay: Number(e.target.value) + }) + } + /> + +
{ courseId, courseShortName, enableAchievements, + enableOverallLeaderboard, + enableContestLeaderboard, enableSourcecast, enableStories, assessmentConfigurations @@ -150,6 +152,13 @@ const NavigationBar: React.FC = () => { text: 'Stories', // TODO: Enable for public deployment disabled: !(isEnrolledInACourse && enableStories) + }, + { + to: `/courses/${courseId}/leaderboard`, + // icon: IconNames.TIMELINE_BAR_CHART, + icon: IconNames.TIMELINE_BAR_CHART, + text: 'Leaderboard', + disabled: !(isEnrolledInACourse && (enableContestLeaderboard || enableOverallLeaderboard)) } ]; }, [ @@ -158,7 +167,9 @@ const NavigationBar: React.FC = () => { enableSourcecast, enableStories, isLoggedIn, - enableAchievements + enableAchievements, + enableContestLeaderboard, + enableOverallLeaderboard ]); const fullAcademyMobileNavbarLeftAdditionalInfo = useMemo( @@ -211,7 +222,8 @@ const NavigationBar: React.FC = () => { '/sicpjs', '/contributors', `/courses/${courseId}/sourcecast`, - `/courses/${courseId}/achievements` + `/courses/${courseId}/achievements`, + `/courses/${courseId}/leaderboard` ]; const enableDesktopPopover = courseId != null && !!topNavbarNavlinks.find(x => location.pathname.startsWith(x)); @@ -306,6 +318,7 @@ const NavigationBar: React.FC = () => { + } /> { courseId: 1, courseShortName: 'CS1101S', enableAchievements: true, + enableContestLeaderboard: false, + enableOverallLeaderboard: false, enableSourcecast: true, assessmentConfigurations: [ { diff --git a/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap b/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap index 5f40c51fba..eed1919107 100644 --- a/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap +++ b/src/commons/navigationBar/__tests__/__snapshots__/NavigationBar.tsx.snap @@ -83,6 +83,12 @@ exports[`NavigationBar Renders "Not logged in" correctly 1`] = ` text="Stories" to="/courses/undefined/stories" /> + + } path="/sicpjs/:section?" @@ -292,6 +302,12 @@ exports[`NavigationBar Renders correctly for student with course 1`] = ` text="Stories" to="/courses/1/stories" /> + + } path="/sicpjs/:section?" @@ -447,6 +467,12 @@ exports[`NavigationBar Renders correctly for student without course 1`] = ` text="Stories" to="/courses/undefined/stories" /> + + } path="/sicpjs/:section?" diff --git a/src/commons/sagas/LeaderboardSaga.ts b/src/commons/sagas/LeaderboardSaga.ts new file mode 100644 index 0000000000..6e4214336d --- /dev/null +++ b/src/commons/sagas/LeaderboardSaga.ts @@ -0,0 +1,80 @@ +import { call, put } from 'redux-saga/effects'; +import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; + +import { Tokens } from '../application/types/SessionTypes'; +import { combineSagaHandlers } from '../redux/utils'; +import { actions } from '../utils/ActionsHelper'; +import { selectTokens } from './BackendSaga'; +import { + getAllContests, + getAllTotalXp, + getContestPopularVoteLeaderboard, + getContestScoreLeaderboard, + getPaginatedTotalXp +} from './RequestsSaga'; + +const LeaderboardSaga = combineSagaHandlers({ + [LeaderboardActions.getAllUsersXp.type]: function* () { + const tokens: Tokens = yield selectTokens(); + + const usersXp = yield call(getAllTotalXp, tokens); + + if (usersXp) { + yield put(actions.saveAllUsersXp(usersXp)); + } + }, + + [LeaderboardActions.getPaginatedLeaderboardXp.type]: function* (action) { + const tokens: Tokens = yield selectTokens(); + const { page, pageSize } = action.payload; + + const paginatedUsersXp = yield call(getPaginatedTotalXp, page, pageSize, tokens); + + if (paginatedUsersXp) { + yield put(actions.savePaginatedLeaderboardXp(paginatedUsersXp)); + } + }, + + [LeaderboardActions.getAllContestScores.type]: function* (action) { + const tokens: Tokens = yield selectTokens(); + const { assessmentId, visibleEntries } = action.payload; + + const contestScores = yield call( + getContestScoreLeaderboard, + assessmentId, + visibleEntries, + tokens + ); + + if (contestScores) { + yield put(actions.saveAllContestScores(contestScores)); + } + }, + + [LeaderboardActions.getAllContestPopularVotes.type]: function* (action) { + const tokens: Tokens = yield selectTokens(); + const { assessmentId, visibleEntries } = action.payload; + + const contestPopularVotes = yield call( + getContestPopularVoteLeaderboard, + assessmentId, + visibleEntries, + tokens + ); + if (contestPopularVotes) { + yield put(actions.saveAllContestPopularVotes(contestPopularVotes)); + } + }, + + [LeaderboardActions.getContests.type]: function* () { + const tokens: Tokens = yield selectTokens(); + + const contests = yield call(getAllContests, tokens); + + if (contests) { + yield put(actions.saveContests(contests)); + } + } +}); + +export default LeaderboardSaga; diff --git a/src/commons/sagas/MainSaga.ts b/src/commons/sagas/MainSaga.ts index ed9ec1e3f0..f95d60f00e 100644 --- a/src/commons/sagas/MainSaga.ts +++ b/src/commons/sagas/MainSaga.ts @@ -6,6 +6,7 @@ import Constants from '../utils/Constants'; import AchievementSaga from './AchievementSaga'; import BackendSaga from './BackendSaga'; import GitHubPersistenceSaga from './GitHubPersistenceSaga'; +import LeaderboardSaga from './LeaderboardSaga'; import LoginSaga from './LoginSaga'; import PersistenceSaga from './PersistenceSaga'; import PlaygroundSaga from './PlaygroundSaga'; @@ -21,6 +22,7 @@ export default function* MainSaga(): SagaIterator { fork(LoginSaga), fork(PlaygroundSaga), fork(AchievementSaga), + fork(LeaderboardSaga), fork(PersistenceSaga), fork(GitHubPersistenceSaga), fork(RemoteExecutionSaga), diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 29f88df173..d127f93a82 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1,5 +1,10 @@ import { call } from 'redux-saga/effects'; import { backendParamsToProgressStatus } from 'src/features/grading/GradingUtils'; +import { + ContestLeaderboardRow, + LeaderboardContestDetails, + LeaderboardRow +} from 'src/features/leaderboard/LeaderboardTypes'; import { OptionType } from 'src/pages/academy/teamFormation/subcomponents/TeamFormationForm'; import { @@ -457,6 +462,159 @@ export const getTotalXp = async (tokens: Tokens, courseRegId?: number): Promise< return totalXp; }; +/** + * GET /courses/{courseId}/all_user_xp + */ +export const getAllTotalXp = async (tokens: Tokens): Promise => { + const resp = await request(`${courseId()}/all_users_xp`, 'GET', { + ...tokens + }); + + if (!resp || !resp.ok) { + return null; // invalid accessToken _and_ refreshToken + } + + const rows = await resp.json(); + + return rows.users.map( + (row: any): LeaderboardRow => ({ + rank: row.rank, + name: row.name, + username: row.username, + xp: row.total_xp, + avatar: '', + achievements: '' + }) + ); +}; + +/** + * GET /courses/{courseId}/get_paginated_display + */ +export const getPaginatedTotalXp = async ( + page: number, + pageSize: number, + tokens: Tokens +): Promise<{ rows: LeaderboardRow[]; userCount: number } | null> => { + const offset = (page - 1) * pageSize; + const params = new URLSearchParams({ offset: `${offset}`, page_size: `${pageSize}` }); + const resp = await request(`${courseId()}/get_paginated_display?${params.toString()}`, 'GET', { + ...tokens + }); + + if (!resp || !resp.ok) { + return null; // invalid accessToken _and_ refreshToken + } + + const data = await resp.json(); + + const rows = data.users.map( + (row: any): LeaderboardRow => ({ + rank: row.rank, + name: row.name, + username: row.username, + xp: row.total_xp, + avatar: '', + achievements: '' + }) + ); + + return { rows: rows, userCount: data.total_count }; +}; + +/** + * GET /courses/{courseId}/assessments/{assessmentid}/scoreLeaderboard + */ +export const getContestScoreLeaderboard = async ( + assessmentId: number, + visibleEntries: number, + tokens: Tokens +): Promise => { + const params = new URLSearchParams({ visible_entries: `${visibleEntries}` }); + const resp = await request( + `${courseId()}/assessments/${assessmentId}/scoreLeaderboard?${params.toString()}`, + 'GET', + { + ...tokens + } + ); + + if (!resp || !resp.ok) { + return null; // invalid accessToken _and_ refreshToken + } + + const rows = await resp.json(); + + return rows.leaderboard.map( + (row: any): ContestLeaderboardRow => ({ + rank: row.rank, + name: row.student_name, + username: row.student_username, + score: row.final_score, + avatar: '', + code: row.answer, + submissionId: row.submission_id, + votingId: rows.voting_id + }) + ); +}; + +/** + * GET /courses/{courseId}/assessments/{assessmentid}/popularVoteLeaderboard + */ +export const getContestPopularVoteLeaderboard = async ( + assessmentId: number, + visibleEntries: number, + tokens: Tokens +): Promise => { + const params = new URLSearchParams({ visible_entries: `${visibleEntries}` }); + const resp = await request( + `${courseId()}/assessments/${assessmentId}/popularVoteLeaderboard?${params.toString()}`, + 'GET', + { + ...tokens + } + ); + + if (!resp || !resp.ok) { + return null; // invalid accessToken _and_ refreshToken + } + + const rows = await resp.json(); + + return rows.leaderboard.map( + (row: any): ContestLeaderboardRow => ({ + rank: row.rank, + name: row.student_name, + username: row.student_username, + score: row.final_score, + avatar: '', + code: row.answer, + submissionId: row.submission_id, + votingId: rows.voting_id + }) + ); +}; + +/** + * GET /courses/{courseId}/all_contests + */ +export const getAllContests = async ( + tokens: Tokens +): Promise => { + const resp = await request(`${courseId()}/all_contests`, 'GET', { + ...tokens + }); + + if (!resp || !resp.ok) { + return null; // invalid accessToken _and_ refreshToken + } + + const rows = await resp.json(); + + return rows; +}; + /** * GET /courses/{courseId}/admin/users/{course_reg_id}/assessments */ @@ -1142,14 +1300,52 @@ export const deleteSourcecastEntry = async ( }; /** - * GET /courses/{courseId}/admin/assessments/{assessmentId}/scoreLeaderboard + * POST /courses/{courseId}/admin/assessments/{assessmentId}/calculateContestScore + */ +export const calculateContestScore = async ( + assessmentId: number, + tokens: Tokens +): Promise => { + const resp = await request( + `${courseId()}/admin/assessments/${assessmentId}/calculateContestScore`, + 'POST', + { + ...tokens + } + ); + + return resp; +}; + +/** + * POST /courses/{courseId}/admin/assessments/{assessmentId}/dispatchContestXp + */ +export const dispatchContestXp = async ( + assessmentId: number, + tokens: Tokens +): Promise => { + const resp = await request( + `${courseId()}/admin/assessments/${assessmentId}/dispatchContestXp`, + 'POST', + { + ...tokens + } + ); + + return resp; +}; + +/** + * GET /courses/{courseId}/assessments/{assessmentId}/scoreLeaderboard */ export const getScoreLeaderboard = async ( assessmentId: number, + visibleEntries: number | undefined, tokens: Tokens ): Promise => { + const params = new URLSearchParams({ visible_entries: `${visibleEntries}` }); const resp = await request( - `${courseId()}/admin/assessments/${assessmentId}/scoreLeaderboard`, + `${courseId()}/assessments/${assessmentId}/scoreLeaderboard?${params.toString()}`, 'GET', { ...tokens @@ -1159,18 +1355,29 @@ export const getScoreLeaderboard = async ( return null; // invalid accessToken _and_ refreshToken } const scoreLeaderboard = await resp.json(); - return scoreLeaderboard as ContestEntry[]; + + return scoreLeaderboard.leaderboard.map( + (row: any): ContestEntry => ({ + rank: row.rank, + student_name: row.student_name, + final_score: row.final_score, + answer: row.answer, + submission_id: row.submission_id + }) + ); }; /** - * GET /courses/{courseId}/admin/assessments/{assessmentId}/popularVoteLeaderboard + * GET /courses/{courseId}/assessments/{assessmentId}/popularVoteLeaderboard */ export const getPopularVoteLeaderboard = async ( assessmentId: number, + visibleEntries: number | undefined, tokens: Tokens ): Promise => { + const params = new URLSearchParams({ visible_entries: `${visibleEntries}` }); const resp = await request( - `${courseId()}/admin/assessments/${assessmentId}/popularVoteLeaderboard`, + `${courseId()}/assessments/${assessmentId}/popularVoteLeaderboard?${params.toString()}`, 'GET', { ...tokens @@ -1180,7 +1387,15 @@ export const getPopularVoteLeaderboard = async ( return null; // invalid accessToken _and_ refreshToken } const popularVoteLeaderboard = await resp.json(); - return popularVoteLeaderboard as ContestEntry[]; + return popularVoteLeaderboard.leaderboard.map( + (row: any): ContestEntry => ({ + rank: row.rank, + student_name: row.student_name, + final_score: row.final_score, + answer: row.answer, + submission_id: row.submission_id + }) + ); }; /** diff --git a/src/commons/sagas/__tests__/BackendSaga.ts b/src/commons/sagas/__tests__/BackendSaga.ts index a92f0fde07..98ef70165e 100644 --- a/src/commons/sagas/__tests__/BackendSaga.ts +++ b/src/commons/sagas/__tests__/BackendSaga.ts @@ -131,8 +131,12 @@ const mockCourseConfiguration1: CourseConfiguration = { viewable: true, enableGame: true, enableAchievements: true, + enableOverallLeaderboard: true, + enableContestLeaderboard: true, enableSourcecast: true, enableStories: false, + topLeaderboardDisplay: 100, + topContestLeaderboardDisplay: 10, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', @@ -162,6 +166,10 @@ const mockCourseConfiguration2: CourseConfiguration = { viewable: true, enableGame: true, enableAchievements: true, + enableOverallLeaderboard: true, + enableContestLeaderboard: true, + topLeaderboardDisplay: 100, + topContestLeaderboardDisplay: 10, enableSourcecast: true, enableStories: false, sourceChapter: Chapter.SOURCE_4, @@ -933,6 +941,10 @@ describe('Test UPDATE_COURSE_CONFIG action', () => { viewable: true, enableGame: false, enableAchievements: false, + enableOverallLeaderboard: true, + enableContestLeaderboard: true, + topLeaderboardDisplay: 100, + topContestLeaderboardDisplay: 10, enableSourcecast: false, enableStories: false, sourceChapter: Chapter.SOURCE_4, @@ -1031,6 +1043,8 @@ describe('Test CREATE_COURSE action', () => { viewable: true, enableGame: true, enableAchievements: true, + enableOverallLeaderboard: true, + enableContestLeaderboard: true, enableSourcecast: true, enableStories: false, sourceChapter: Chapter.SOURCE_1, diff --git a/src/commons/sideContent/content/SideContentContestLeaderboard.tsx b/src/commons/sideContent/content/SideContentContestLeaderboard.tsx index f4494056bd..4004633bfe 100644 --- a/src/commons/sideContent/content/SideContentContestLeaderboard.tsx +++ b/src/commons/sideContent/content/SideContentContestLeaderboard.tsx @@ -83,7 +83,7 @@ const SideContentContestLeaderboard: React.FC )) ) : ( diff --git a/src/commons/utils/ActionsHelper.ts b/src/commons/utils/ActionsHelper.ts index 129fcc17a5..2b7b7bf029 100644 --- a/src/commons/utils/ActionsHelper.ts +++ b/src/commons/utils/ActionsHelper.ts @@ -1,3 +1,5 @@ +import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; + import CommonsActions from '../../commons/application/actions/CommonsActions'; import InterpreterActions from '../../commons/application/actions/InterpreterActions'; import SessionActions from '../../commons/application/actions/SessionActions'; @@ -23,6 +25,7 @@ import type { ActionType } from './TypeHelper'; export const actions = { ...AchievementActions, + ...LeaderboardActions, ...CommonsActions, ...CollabEditingActions, ...DashboardActions, diff --git a/src/features/academy/__tests__/AcademyActions.ts b/src/features/academy/__tests__/AcademyActions.ts index ba08b5660f..451b57c74e 100644 --- a/src/features/academy/__tests__/AcademyActions.ts +++ b/src/features/academy/__tests__/AcademyActions.ts @@ -29,6 +29,8 @@ test('createCourse generates correct action object', () => { viewable: true, enableGame: true, enableAchievements: true, + enableOverallLeaderboard: true, + enableContestLeaderboard: true, enableSourcecast: true, enableStories: false, sourceChapter: Chapter.SOURCE_1, diff --git a/src/features/leaderboard/LeaderboardActions.ts b/src/features/leaderboard/LeaderboardActions.ts new file mode 100644 index 0000000000..493f89a706 --- /dev/null +++ b/src/features/leaderboard/LeaderboardActions.ts @@ -0,0 +1,33 @@ +import { createActions } from 'src/commons/redux/utils'; + +import { + ContestLeaderboardRow, + LeaderboardContestDetails, + LeaderboardRow +} from './LeaderboardTypes'; + +const LeaderboardActions = createActions('leaderboard', { + getAllUsersXp: 0, + saveAllUsersXp: (userXp: LeaderboardRow[]) => userXp, + getPaginatedLeaderboardXp: (page: number, pageSize: number) => ({ page, pageSize }), + savePaginatedLeaderboardXp: (payload: { rows: LeaderboardRow[]; userCount: number }) => payload, + getAllContestScores: (assessmentId: number, visibleEntries: number) => ({ + assessmentId, + visibleEntries + }), + saveAllContestScores: (contestScore: ContestLeaderboardRow[]) => contestScore, + getAllContestPopularVotes: (assessmentId: number, visibleEntries: number) => ({ + assessmentId, + visibleEntries + }), + saveAllContestPopularVotes: (contestPopularVote: ContestLeaderboardRow[]) => contestPopularVote, + getCode: 0, + saveCode: (code: string) => code, + clearCode: 0, + getContests: 0, + saveContests: (contests: LeaderboardContestDetails[]) => contests, + setWorkspaceInitialRun: (contestID: number) => contestID, + resetWorkspaceInitialRun: (contestID: number) => contestID +}); + +export default LeaderboardActions; diff --git a/src/features/leaderboard/LeaderboardReducer.ts b/src/features/leaderboard/LeaderboardReducer.ts new file mode 100644 index 0000000000..afe542dcf9 --- /dev/null +++ b/src/features/leaderboard/LeaderboardReducer.ts @@ -0,0 +1,43 @@ +import { createReducer, Reducer } from '@reduxjs/toolkit'; +import { SourceActionType } from 'src/commons/utils/ActionsHelper'; + +import { defaultLeaderboard } from '../../commons/application/ApplicationTypes'; +import LeaderboardActions from './LeaderboardActions'; +import { LeaderboardState } from './LeaderboardTypes'; + +export const LeaderboardReducer: Reducer = createReducer( + defaultLeaderboard, + builder => { + builder + .addCase(LeaderboardActions.saveAllUsersXp, (state, action) => { + state.userXp = action.payload; + }) + .addCase(LeaderboardActions.savePaginatedLeaderboardXp, (state, action) => { + state.paginatedUserXp = { + rows: action.payload.rows || [], + userCount: action.payload.userCount || 0 + }; + }) + .addCase(LeaderboardActions.saveAllContestScores, (state, action) => { + state.contestScore = action.payload; + }) + .addCase(LeaderboardActions.saveAllContestPopularVotes, (state, action) => { + state.contestPopularVote = action.payload; + }) + .addCase(LeaderboardActions.saveCode, (state, action) => { + state.code = action.payload; + }) + .addCase(LeaderboardActions.clearCode, (state, action) => { + state.code = ''; + }) + .addCase(LeaderboardActions.saveContests, (state, action) => { + state.contests = action.payload; + }) + .addCase(LeaderboardActions.setWorkspaceInitialRun, (state, action) => { + state.initialRun[action.payload] = true; + }) + .addCase(LeaderboardActions.resetWorkspaceInitialRun, (state, action) => { + state.initialRun[action.payload] = false; + }); + } +); diff --git a/src/features/leaderboard/LeaderboardTypes.ts b/src/features/leaderboard/LeaderboardTypes.ts new file mode 100644 index 0000000000..bac5d8188f --- /dev/null +++ b/src/features/leaderboard/LeaderboardTypes.ts @@ -0,0 +1,35 @@ +export type LeaderboardRow = { + rank: number; + name: string; + username: string; + xp: number; + avatar: string; + achievements: string; +}; + +export type ContestLeaderboardRow = { + rank: number; + name: string; + username: string; + score: number; + avatar: string; + code: string; + submissionId: number; + votingId: number; +}; + +export type LeaderboardState = { + userXp: LeaderboardRow[]; + paginatedUserXp: { rows: LeaderboardRow[]; userCount: number }; + contestScore: ContestLeaderboardRow[]; + contestPopularVote: ContestLeaderboardRow[]; + code: string; + contests: LeaderboardContestDetails[]; + initialRun: { [id: number]: boolean }; +}; + +export type LeaderboardContestDetails = { + contest_id: number; + title: string; + published: boolean | undefined; +}; diff --git a/src/pages/__tests__/localStorage.test.ts b/src/pages/__tests__/localStorage.test.ts index d26314adba..883d79424f 100644 --- a/src/pages/__tests__/localStorage.test.ts +++ b/src/pages/__tests__/localStorage.test.ts @@ -19,6 +19,8 @@ const mockShortDefaultState: SavedState = { viewable: defaultState.session.viewable, enableGame: defaultState.session.enableGame, enableAchievements: defaultState.session.enableAchievements, + enableOverallLeaderboard: defaultState.session.enableOverallLeaderboard, + enableContestLeaderboard: defaultState.session.enableContestLeaderboard, enableSourcecast: defaultState.session.enableSourcecast, enableStories: defaultState.session.enableStories, moduleHelpText: defaultState.session.moduleHelpText, diff --git a/src/pages/academy/academyRoutes.tsx b/src/pages/academy/academyRoutes.tsx index d4cc0338d0..41961518e6 100644 --- a/src/pages/academy/academyRoutes.tsx +++ b/src/pages/academy/academyRoutes.tsx @@ -14,6 +14,11 @@ const notFoundPath = 'not_found'; const Game = () => import('./game/Game'); const Sourcecast = () => import('../sourcecast/Sourcecast'); const Achievement = () => import('../achievement/Achievement'); +const Leaderboard = () => import('../leaderboard/Leaderboard'); +const OverallLeaderboardWrapper = () => + import('../leaderboard/subcomponents/OverallLeaderboardWrapper'); +const ContestLeaderboardWrapper = () => + import('../leaderboard/subcomponents/ContestLeaderboardWrapper'); const NotFound = () => import('../notFound/NotFound'); // Memoized for efficiency. Relies on immutability of Redux store to ensure @@ -73,6 +78,9 @@ const getCommonAcademyRoutes = (): RouteObject[] => { }, { path: 'sourcecast/:sourcecastId?', lazy: Sourcecast }, { path: 'achievements/*', lazy: Achievement }, + { path: 'leaderboard/overall', lazy: OverallLeaderboardWrapper }, + { path: 'leaderboard/contests/*', lazy: ContestLeaderboardWrapper }, + { path: 'leaderboard/*', lazy: Leaderboard }, { path: '*', lazy: NotFound } ]; }; diff --git a/src/pages/academy/adminPanel/AdminPanel.tsx b/src/pages/academy/adminPanel/AdminPanel.tsx index d60edb1647..23277bc0b0 100644 --- a/src/pages/academy/adminPanel/AdminPanel.tsx +++ b/src/pages/academy/adminPanel/AdminPanel.tsx @@ -27,6 +27,10 @@ const defaultCourseConfig: UpdateCourseConfiguration = { viewable: true, enableGame: true, enableAchievements: true, + enableOverallLeaderboard: true, + enableContestLeaderboard: true, + topLeaderboardDisplay: 100, + topContestLeaderboardDisplay: 10, enableSourcecast: true, enableStories: false, moduleHelpText: '' @@ -60,6 +64,10 @@ const AdminPanel: React.FC = () => { viewable: session.viewable, enableGame: session.enableGame, enableAchievements: session.enableAchievements, + enableOverallLeaderboard: session.enableOverallLeaderboard, + enableContestLeaderboard: session.enableContestLeaderboard, + topLeaderboardDisplay: session.topLeaderboardDisplay, + topContestLeaderboardDisplay: session.topContestLeaderboardDisplay, enableSourcecast: session.enableSourcecast, enableStories: session.enableStories, moduleHelpText: session.moduleHelpText @@ -68,6 +76,10 @@ const AdminPanel: React.FC = () => { session.courseName, session.courseShortName, session.enableAchievements, + session.enableOverallLeaderboard, + session.enableContestLeaderboard, + session.topLeaderboardDisplay, + session.topContestLeaderboardDisplay, session.enableGame, session.enableSourcecast, session.enableStories, diff --git a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx index 023c659038..822445b50f 100644 --- a/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx +++ b/src/pages/academy/adminPanel/subcomponents/CourseConfigPanel.tsx @@ -36,6 +36,10 @@ const CourseConfigPanel: React.FC = props => { viewable, enableGame, enableAchievements, + enableOverallLeaderboard, + enableContestLeaderboard, + topLeaderboardDisplay, + topContestLeaderboardDisplay, enableSourcecast, enableStories, moduleHelpText @@ -133,6 +137,40 @@ const CourseConfigPanel: React.FC = props => { {courseHelpTextSelectedTab === CourseHelpTextEditorTab.WRITE && writePanel} {courseHelpTextSelectedTab === CourseHelpTextEditorTab.PREVIEW && previewPanel} + + + props.setCourseConfiguration({ + ...props.courseConfiguration, + topLeaderboardDisplay: Number(e.target.value) + }) + } + /> + + + + props.setCourseConfiguration({ + ...props.courseConfiguration, + topContestLeaderboardDisplay: Number(e.target.value) + }) + } + /> +
{!isMobileBreakpoint && }
@@ -186,6 +224,26 @@ const CourseConfigPanel: React.FC = props => { }) } /> + + props.setCourseConfiguration({ + ...props.courseConfiguration, + enableOverallLeaderboard: (e.target as HTMLInputElement).checked + }) + } + /> + + props.setCourseConfiguration({ + ...props.courseConfiguration, + enableContestLeaderboard: (e.target as HTMLInputElement).checked + }) + } + />
diff --git a/src/pages/academy/groundControl/configureControls/CalculateContestScoreButton.tsx b/src/pages/academy/groundControl/configureControls/CalculateContestScoreButton.tsx new file mode 100644 index 0000000000..0ee5a29b4b --- /dev/null +++ b/src/pages/academy/groundControl/configureControls/CalculateContestScoreButton.tsx @@ -0,0 +1,26 @@ +import { IconNames } from '@blueprintjs/icons'; +import ControlButton from 'src/commons/ControlButton'; +import { calculateContestScore } from 'src/commons/sagas/RequestsSaga'; +import { useTokens } from 'src/commons/utils/Hooks'; + +type Props = { + assessmentId: number; +}; + +const CalculateContestScoreButton: React.FC = ({ assessmentId }) => { + const tokens = useTokens(); + + return ( +
+ { + calculateContestScore(assessmentId, tokens); + }} + label="Calculate Contest Score" + /> +
+ ); +}; + +export default CalculateContestScoreButton; diff --git a/src/pages/academy/groundControl/configureControls/DispatchContestXpButton.tsx b/src/pages/academy/groundControl/configureControls/DispatchContestXpButton.tsx new file mode 100644 index 0000000000..84d088da8f --- /dev/null +++ b/src/pages/academy/groundControl/configureControls/DispatchContestXpButton.tsx @@ -0,0 +1,26 @@ +import { IconNames } from '@blueprintjs/icons'; +import ControlButton from 'src/commons/ControlButton'; +import { dispatchContestXp } from 'src/commons/sagas/RequestsSaga'; +import { useTokens } from 'src/commons/utils/Hooks'; + +type Props = { + assessmentId: number; +}; + +const CalculateContestScoreButton: React.FC = ({ assessmentId }) => { + const tokens = useTokens(); + + return ( +
+ { + dispatchContestXp(assessmentId, tokens); + }} + label="Dispatch Contest XP" + /> +
+ ); +}; + +export default CalculateContestScoreButton; diff --git a/src/pages/academy/groundControl/configureControls/ExportScoreLeaderboardButton.tsx b/src/pages/academy/groundControl/configureControls/ExportScoreLeaderboardButton.tsx index f0a375fec6..943c8ffa0c 100644 --- a/src/pages/academy/groundControl/configureControls/ExportScoreLeaderboardButton.tsx +++ b/src/pages/academy/groundControl/configureControls/ExportScoreLeaderboardButton.tsx @@ -2,7 +2,7 @@ import { IconNames } from '@blueprintjs/icons'; import { createGrid, GridOptions } from 'ag-grid-community'; import ControlButton from 'src/commons/ControlButton'; import { getScoreLeaderboard } from 'src/commons/sagas/RequestsSaga'; -import { useTokens } from 'src/commons/utils/Hooks'; +import { useTokens, useTypedSelector } from 'src/commons/utils/Hooks'; type Props = { assessmentId: number; @@ -10,10 +10,11 @@ type Props = { const ExportScoreLeaderboardButton: React.FC = ({ assessmentId }) => { const tokens = useTokens(); + const visibleEntries = useTypedSelector(store => store.session.topContestLeaderboardDisplay); // onClick handler for fetching score leaderboard, putting it into a grid and exporting data const exportScoreLeaderboardToCsv = async () => { - const scoreLeaderbaord = await getScoreLeaderboard(assessmentId, tokens); + const scoreLeaderbaord = await getScoreLeaderboard(assessmentId, visibleEntries, tokens); const gridContainer = document.createElement('div'); const gridOptions: GridOptions = { rowData: scoreLeaderbaord, diff --git a/src/pages/academy/groundControl/configureControls/ExportVoteLeaderboardButton.tsx b/src/pages/academy/groundControl/configureControls/ExportVoteLeaderboardButton.tsx index 912a91c5c6..2e4cc749ff 100644 --- a/src/pages/academy/groundControl/configureControls/ExportVoteLeaderboardButton.tsx +++ b/src/pages/academy/groundControl/configureControls/ExportVoteLeaderboardButton.tsx @@ -2,7 +2,7 @@ import { IconNames } from '@blueprintjs/icons'; import { createGrid, GridOptions } from 'ag-grid-community'; import ControlButton from 'src/commons/ControlButton'; import { getPopularVoteLeaderboard } from 'src/commons/sagas/RequestsSaga'; -import { useTokens } from 'src/commons/utils/Hooks'; +import { useTokens, useTypedSelector } from 'src/commons/utils/Hooks'; type Props = { assessmentId: number; @@ -10,10 +10,15 @@ type Props = { const ExportVoteLeaderboardButton: React.FC = ({ assessmentId }) => { const tokens = useTokens({ throwWhenEmpty: true }); + const visibleEntries = useTypedSelector(store => store.session.topContestLeaderboardDisplay); // onClick handler for fetching popular vote leaderboard, putting it into a grid and exporting data const exportPopularVoteLeaderboardToCsv = async () => { - const popularVoteLeaderboard = await getPopularVoteLeaderboard(assessmentId, tokens); + const popularVoteLeaderboard = await getPopularVoteLeaderboard( + assessmentId, + visibleEntries, + tokens + ); const gridContainer = document.createElement('div'); const gridOptions: GridOptions = { rowData: popularVoteLeaderboard, diff --git a/src/pages/academy/groundControl/subcomponents/GroundControlConfigureCell.tsx b/src/pages/academy/groundControl/subcomponents/GroundControlConfigureCell.tsx index 8c2737e3bf..23e67b883e 100644 --- a/src/pages/academy/groundControl/subcomponents/GroundControlConfigureCell.tsx +++ b/src/pages/academy/groundControl/subcomponents/GroundControlConfigureCell.tsx @@ -14,6 +14,8 @@ import React, { useCallback, useState } from 'react'; import { AssessmentOverview } from '../../../../commons/assessment/AssessmentTypes'; import ControlButton from '../../../../commons/ControlButton'; +import CalculateContestScoreButton from '../configureControls/CalculateContestScoreButton'; +import DispatchContestXpButton from '../configureControls/DispatchContestXpButton'; import ExportScoreLeaderboardButton from '../configureControls/ExportScoreLeaderboardButton'; import ExportVoteLeaderboardButton from '../configureControls/ExportVoteLeaderboardButton'; import AssignEntriesButton from './configureControls/AssignEntriesButton'; @@ -117,6 +119,8 @@ const ConfigureCell: React.FC = ({ />
+ + { + const enableOverallLeaderboard = useTypedSelector( + store => store.session.enableOverallLeaderboard + ); + const enableContestLeaderboard = useTypedSelector( + store => store.session.enableContestLeaderboard + ); + const contestAssessments = useTypedSelector(store => store.session.assessmentOverviews); + const defaultContest = + contestAssessments?.find( + assessment => assessment.type == 'Contests' && assessment.isPublished + ) ?? null; + + const courseId = useTypedSelector(store => store.session.courseId); + const baseLink = `/courses/${courseId}/leaderboard`; + + return ( + + + ) : enableContestLeaderboard && defaultContest != null ? ( + + ) : ( + + ) + } + > + + + ) : ( + + ) + } + > + + ); +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = Leaderboard; +Component.displayName = 'Leaderboard'; + +export default Leaderboard; diff --git a/src/pages/leaderboard/subcomponents/ContestLeaderboard.tsx b/src/pages/leaderboard/subcomponents/ContestLeaderboard.tsx new file mode 100644 index 0000000000..d27d903c81 --- /dev/null +++ b/src/pages/leaderboard/subcomponents/ContestLeaderboard.tsx @@ -0,0 +1,210 @@ +import 'ag-grid-community/styles/ag-grid.css'; +import 'ag-grid-community/styles/ag-theme-alpine.css'; +import 'src/styles/Leaderboard.scss'; + +import { ColDef } from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; +import { + ContestLeaderboardRow, + LeaderboardContestDetails +} from 'src/features/leaderboard/LeaderboardTypes'; + +import default_avatar from '../../../assets/default-avatar.jpg'; +import leaderboard_background from '../../../assets/leaderboard_background.jpg'; +import LeaderboardDropdown from './LeaderboardDropdown'; +import LeaderboardExportButton from './LeaderboardExportButton'; +import LeaderboardPodium from './LeaderboardPodium'; + +type Props = { + type: string; + contestID: number; +}; + +const ContestLeaderboard: React.FC = ({ type, contestID }) => { + const courseID = useTypedSelector(store => store.session.courseId); + const visibleEntries = useTypedSelector( + store => store.session?.topContestLeaderboardDisplay ?? 10 + ); + const dispatch = useDispatch(); + + // Retrieve Contest Score Data from store + const rankedLeaderboard: ContestLeaderboardRow[] = useTypedSelector(store => + type === 'score' ? store.leaderboard.contestScore : store.leaderboard.contestPopularVote + ); + + useEffect(() => { + if (type === 'score') { + dispatch(LeaderboardActions.getAllContestScores(contestID, visibleEntries)); + } else { + dispatch(LeaderboardActions.getAllContestPopularVotes(contestID, visibleEntries)); + } + }, [dispatch, contestID, type]); + + // Retrieve contests (For dropdown) + const contestDetails: LeaderboardContestDetails[] = useTypedSelector( + store => store.leaderboard.contests + ); + const contestName = contestDetails.find(contest => contest.contest_id === contestID)?.title; + + useEffect(() => { + dispatch(LeaderboardActions.getContests()); + }, [dispatch]); + + // Temporary loading of leaderboard background + useEffect(() => { + const originalBackground = document.body.style.background; + document.body.style.background = `url(${leaderboard_background}) center/cover no-repeat fixed`; + return () => { + // Cleanup + document.body.style.background = originalBackground; + }; + }, []); + + // Display constants + const top3 = rankedLeaderboard.filter(row => row.rank <= 3); + const rest = rankedLeaderboard + .filter(row => row.rank <= Number(visibleEntries)) + .slice(top3.length); + + // Set sample profile pictures (Seeded random) + function convertToRandomNumber(id: string): number { + const str = id.slice(1); + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + } + return (Math.abs(hash) % 7) + 1; + } + + rankedLeaderboard.map((row: ContestLeaderboardRow) => { + row.avatar = `/assets/Sample_Profile_${convertToRandomNumber(row.username)}.jpg`; + }); + + // const workspaceLocation = 'assessment'; + const navigate = useNavigate(); + const handleLinkClick = (code: string, votingId: number) => { + dispatch(LeaderboardActions.saveCode(code)); + navigate(`/courses/${courseID}/contests/${votingId}/0`); + }; + + // Define column definitions for ag-Grid + const columnDefs: ColDef[] = useMemo( + () => [ + { + field: 'rank', + suppressMovable: true, + headerName: 'Rank', + width: 84, + sortable: true, + cellRenderer: (params: any) => { + const rank = params.value; + const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ''; + return `${rank} ${medal}`; + } + }, + { + field: 'avatar', + suppressMovable: true, + headerName: 'Avatar', + width: 180, + sortable: false, + cellRenderer: (params: any) => ( + avatar (e.currentTarget.src = default_avatar)} + style={{ width: '40px', height: '40px', borderRadius: '50%' }} + /> + ) + }, + { field: 'name', suppressMovable: true, headerName: 'Name', width: 520, sortable: true }, + { + field: 'score', + suppressMovable: true, + headerName: 'Score', + width: 154, + sortable: true, + valueFormatter: params => params.value?.toFixed(2) + }, + { + field: 'code', + suppressMovable: true, + headerName: 'Code', + width: 260, + sortable: false, + cellRenderer: (params: any) => ( + { + e.preventDefault(); + handleLinkClick(params.data.code, params.data.votingId); + }} + style={{ color: 'white', fontStyle: 'italic' }} + > + 🔗 Open Code + + ) + } + ], + [] + ); + + return ( +
+ {/* Top 3 Ranking */} + + +
+ {/* Leaderboard Options Dropdown */} + + + {/* Export Button */} + +
+ + {/* Leaderboard Table (Top 3) */} +
+

Contest Winners

+ 10} + paginationPageSize={10} + paginationPageSizeSelector={[10]} + /> +
+ +
+ + {/* Honourable Mentions */} +
+

Honourable Mentions

+ +
+
+ ); +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = ContestLeaderboard; +Component.displayName = 'ContestLeaderboard'; + +export default ContestLeaderboard; diff --git a/src/pages/leaderboard/subcomponents/ContestLeaderboardWrapper.tsx b/src/pages/leaderboard/subcomponents/ContestLeaderboardWrapper.tsx new file mode 100644 index 0000000000..be83693703 --- /dev/null +++ b/src/pages/leaderboard/subcomponents/ContestLeaderboardWrapper.tsx @@ -0,0 +1,111 @@ +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { Navigate, Route, Routes, useLocation, useParams } from 'react-router'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; +import { LeaderboardContestDetails } from 'src/features/leaderboard/LeaderboardTypes'; + +import NotFound from '../../notFound/NotFound'; +import ContestLeaderboard from './ContestLeaderboard'; + +const ContestLeaderboardWrapper: React.FC = () => { + const enableContestLeaderboard = useTypedSelector( + store => store.session.enableContestLeaderboard + ); + + const dispatch = useDispatch(); + const contestAssessments: LeaderboardContestDetails[] = useTypedSelector( + store => store.leaderboard.contests + ); + + useEffect(() => { + dispatch(LeaderboardActions.getContests()); + }, [dispatch]); + + const defaultContest = contestAssessments?.find(assessment => assessment.published) ?? null; + + const courseId = useTypedSelector(store => store.session.courseId); + const baseLink = `/courses/${courseId}/leaderboard`; + + return ( + + + ) : ( + + ) + } + > + + + ) : ( + + ) + } + > + + + ) : ( + + ) + } + > + + }> + + ); +}; + +const ContestLeaderboardWrapperHelper: React.FC<{ + baseLink: string; + contestDetails: LeaderboardContestDetails[]; + type: 'score' | 'popularvote'; +}> = ({ baseLink, contestDetails, type }) => { + let { id } = useParams<{ id: string }>(); + const location = useLocation(); + + if (!id) { + const match = location.pathname.match(/\/contests\/(\d+)\//); + if (match) { + id = match[1]; + } + } + + if (!contestDetails.length) { + // Wait for contestDetails to load + return null; + } + const contest = contestDetails.find(contest => contest.contest_id === parseInt(id ?? '', 10)); + + return contest !== undefined && contest.published ? ( + + ) : ( + + ); +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = ContestLeaderboardWrapper; +Component.displayName = 'ContestLeaderboardWrapper'; + +export default ContestLeaderboardWrapper; diff --git a/src/pages/leaderboard/subcomponents/LeaderboardDropdown.tsx b/src/pages/leaderboard/subcomponents/LeaderboardDropdown.tsx new file mode 100644 index 0000000000..a569a440b7 --- /dev/null +++ b/src/pages/leaderboard/subcomponents/LeaderboardDropdown.tsx @@ -0,0 +1,76 @@ +import 'src/styles/Leaderboard.scss'; + +import React from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import { LeaderboardContestDetails } from 'src/features/leaderboard/LeaderboardTypes'; + +type Props = { + contests: LeaderboardContestDetails[]; +}; + +const LeaderboardDropdown: React.FC = ({ contests }) => { + const enableOverallLeaderboard = useTypedSelector( + store => store.session.enableOverallLeaderboard + ); + const enableContestLeaderboard = useTypedSelector( + store => store.session.enableContestLeaderboard + ); + const crid = useTypedSelector(store => store.session.courseId); + const baseLink = `/courses/${crid}/leaderboard`; + + // Handle Navigation to other contest leaderboards + const navigate = useNavigate(); + const location = useLocation(); + const handleChange = (e: React.ChangeEvent) => { + const selectedValue = e.target.value; + navigate(selectedValue); + }; + + const currentPath = location.pathname; + const publishedContests = enableContestLeaderboard + ? contests.filter(contest => contest.published) + : []; + + return ( + <> + {/* Leaderboard Options Dropdown */} + + + ); +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = LeaderboardDropdown; +Component.displayName = 'LeaderboardDropdown'; + +export default LeaderboardDropdown; diff --git a/src/pages/leaderboard/subcomponents/LeaderboardExportButton.tsx b/src/pages/leaderboard/subcomponents/LeaderboardExportButton.tsx new file mode 100644 index 0000000000..5e69749353 --- /dev/null +++ b/src/pages/leaderboard/subcomponents/LeaderboardExportButton.tsx @@ -0,0 +1,125 @@ +import 'src/styles/Leaderboard.scss'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; +import { ContestLeaderboardRow, LeaderboardRow } from 'src/features/leaderboard/LeaderboardTypes'; + +import { Role } from '../../../commons/application/ApplicationTypes'; + +type Props = { + type: string; + contest?: string; + contestID?: number; +}; + +const LeaderboardExportButton: React.FC = ({ type, contest, contestID }) => { + // Retrieve relevant leaderboard data + const [exportRequested, setExportRequest] = useState(false); + const dispatch = useDispatch(); + const selectData = (type: string) => { + switch (type) { + case 'overall': + return (store: { leaderboard: { userXp: any } }) => store.leaderboard.userXp; + case 'score': + return (store: { leaderboard: { contestScore: any } }) => store.leaderboard.contestScore; + default: + return (store: { leaderboard: { contestPopularVote: any } }) => + store.leaderboard.contestPopularVote; + } + }; + + const selector = useMemo(() => selectData(type), [type]); + const data = useTypedSelector(selector); + + const visibleEntries = Number.MAX_SAFE_INTEGER; + + const onExportClick = () => { + // Dispatch relevant request + if (type == 'overall') dispatch(LeaderboardActions.getAllUsersXp()); + else if (type == 'score') + dispatch(LeaderboardActions.getAllContestScores(contestID as number, visibleEntries)); + else + dispatch(LeaderboardActions.getAllContestPopularVotes(contestID as number, visibleEntries)); + setExportRequest(true); + }; + + // Return the CSV when requested and data is loaded + useEffect(() => { + if (exportRequested) { + exportCSV(); + setExportRequest(false); // Clear request + } + }, [data]); + + const escapeCodeField = (value: any) => { + const str = value?.toString() ?? ''; + const escaped = str.replace(/"/g, '""'); + return `"${escaped}"`; + }; + + const role = useTypedSelector(store => store.session.role); + const exportCSV = () => { + const headers = [ + 'Rank', + 'Name', + 'Username', + type === 'overall' ? 'XP' : 'Score', + type === 'overall' ? 'Achievements' : 'Code' + ]; + const rows = data?.map( + (player: { + rank: any; + name: any; + username: any; + xp?: number; + avatar?: string; + achievements?: string; + score?: number; + code?: string; + submissionId?: number; + votingId?: number; + }) => [ + player.rank, + player.name, + player.username, + type === 'overall' + ? (player as LeaderboardRow).xp + : (player as ContestLeaderboardRow).score, + type === 'overall' + ? (player as LeaderboardRow).achievements + : escapeCodeField((player as ContestLeaderboardRow).code) + ] + ); + + // Combine headers and rows + const csvContent = [headers.join(','), ...rows.map((row: any[]) => row.join(','))].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = + type === 'overall' + ? 'Overall Leaderboard.csv' + : type === 'popularvote' + ? `${contest} Popular Vote Leaderboard.csv` + : `${contest} Score Leaderboard.csv`; // Filename for download + link.click(); + }; + + return role === Role.Admin || role === Role.Staff ? ( + + ) : ( + '' + ); +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = LeaderboardExportButton; +Component.displayName = 'LeaderboardExportButton'; + +export default LeaderboardExportButton; diff --git a/src/pages/leaderboard/subcomponents/LeaderboardPodium.tsx b/src/pages/leaderboard/subcomponents/LeaderboardPodium.tsx new file mode 100644 index 0000000000..36ca6ef495 --- /dev/null +++ b/src/pages/leaderboard/subcomponents/LeaderboardPodium.tsx @@ -0,0 +1,45 @@ +import 'src/styles/Leaderboard.scss'; + +import React from 'react'; +import { ContestLeaderboardRow, LeaderboardRow } from 'src/features/leaderboard/LeaderboardTypes'; + +type Props = + | { type: 'overall'; data: LeaderboardRow[]; outputType: undefined } + | { type: 'contest'; data: ContestLeaderboardRow[]; outputType: 'image' } + | { type: 'contest'; data: ContestLeaderboardRow[]; outputType: 'audio' }; + +const LeaderboardPodium: React.FC = ({ type, data, outputType }) => { + // TODO: Retrieval of rune image/audio files from backend to be displayed on the podium + + return ( +
+ {data + .filter(x => x.rank <= 3) + .slice(0, 3) + .map((player, index) => ( +
+

{player.name}

+
+

{player.rank}

+

+ {type === 'overall' + ? (player as LeaderboardRow).xp + : (player as ContestLeaderboardRow).score.toFixed(2)} + {type === 'overall' ? ' XP' : ''} +

+
+
+ ))} +
+ ); +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = LeaderboardPodium; +Component.displayName = 'LeaderboardPodium'; + +export default LeaderboardPodium; diff --git a/src/pages/leaderboard/subcomponents/OverallLeaderboard.tsx b/src/pages/leaderboard/subcomponents/OverallLeaderboard.tsx new file mode 100644 index 0000000000..86a8205ba7 --- /dev/null +++ b/src/pages/leaderboard/subcomponents/OverallLeaderboard.tsx @@ -0,0 +1,192 @@ +import 'ag-grid-community/styles/ag-grid.css'; +import 'ag-grid-community/styles/ag-theme-alpine.css'; +import 'src/styles/Leaderboard.scss'; + +import { ColDef, IDatasource } from 'ag-grid-community'; +import { AgGridReact } from 'ag-grid-react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import default_avatar from 'src/assets/default-avatar.jpg'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import LeaderboardActions from 'src/features/leaderboard/LeaderboardActions'; +import { + LeaderboardContestDetails, + LeaderboardRow +} from 'src/features/leaderboard/LeaderboardTypes'; + +import leaderboard_background from '../../../assets/leaderboard_background.jpg'; +import LeaderboardDropdown from './LeaderboardDropdown'; +import LeaderboardExportButton from './LeaderboardExportButton'; +import LeaderboardPodium from './LeaderboardPodium'; + +const OverallLeaderboard: React.FC = () => { + const dispatch = useDispatch(); + + // Retrieve contests (For dropdown) + const contestDetails: LeaderboardContestDetails[] = useTypedSelector( + store => store.leaderboard.contests + ); + + useEffect(() => { + dispatch(LeaderboardActions.getContests()); + }, [dispatch]); + + // Temporary loading of leaderboard background + useEffect(() => { + const originalBackground = document.body.style.background; + document.body.style.background = `url(${leaderboard_background}) center/cover no-repeat fixed`; + return () => { + // Cleanup + document.body.style.background = originalBackground; + }; + }, []); + + // Define column definitions for ag-Grid + const columnDefs: ColDef[] = useMemo( + () => [ + { + field: 'rank', + suppressMovable: true, + headerName: 'Rank', + width: 84, + sortable: true, + cellRenderer: (params: any) => { + const rank = params.value; + const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ''; + return `${rank} ${medal}`; + } + }, + { + field: 'avatar', + suppressMovable: true, + headerName: 'Avatar', + width: 180, + sortable: false, + cellRenderer: (params: any) => ( + avatar (e.currentTarget.src = default_avatar)} + style={{ width: '40px', height: '40px', borderRadius: '50%' }} + /> + ) + }, + { field: 'name', suppressMovable: true, headerName: 'Name', width: 520, sortable: true }, + { + field: 'xp', + suppressMovable: true, + headerName: 'XP', + width: 414 /*154*/, + sortable: true + } /*, + { + field: 'achievements', + suppressMovable: true, + sortable: false, + headerName: 'Achievements', + width: 260 + } + */ + ], + [] + ); + + const paginatedLeaderboard: { rows: LeaderboardRow[]; userCount: number } = useTypedSelector( + store => store.leaderboard.paginatedUserXp + ); + const pageSize = 25; + const visibleEntries = useTypedSelector( + store => store.session?.topLeaderboardDisplay ?? Number.MAX_SAFE_INTEGER + ); + const [top3Leaderboard, setTop3Leaderboard] = useState([]); + + useEffect(() => { + dispatch(LeaderboardActions.getPaginatedLeaderboardXp(1, pageSize)); + }, [dispatch]); + + const latestParamsRef = useRef(null); + const dataSourceRef = useRef({ + getRows: async (params: any) => { + const startRow = params.startRow; + const endRow = params.endRow; + + const pageSize = endRow - startRow; + const page = startRow / pageSize + 1; + + dispatch(LeaderboardActions.getPaginatedLeaderboardXp(page, pageSize)); + + // Params stored to prevent re-rendering + latestParamsRef.current = params; + } + }); + + useEffect(() => { + if (latestParamsRef.current && paginatedLeaderboard.rows.length > 0) { + const { successCallback } = latestParamsRef.current; + + if (latestParamsRef.current.startRow === 0) { + setTop3Leaderboard(paginatedLeaderboard.rows.slice(0, 3)); + } + + successCallback( + paginatedLeaderboard.rows, + Math.min(paginatedLeaderboard.userCount, visibleEntries) + ); + latestParamsRef.current = null; + } + }, [paginatedLeaderboard]); + + // Set sample profile pictures (Seeded random) + function convertToRandomNumber(id: string): number { + const str = id.slice(1); + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + } + return (Math.abs(hash) % 7) + 1; + } + + paginatedLeaderboard.rows.map((row: LeaderboardRow) => { + row.avatar = `/assets/Sample_Profile_${convertToRandomNumber(row.username)}.jpg`; + }); + + return ( +
+ {/* Top 3 Ranking */} + + +
+ {/* Leaderboard Options Dropdown */} + + + {/* Export Button */} + +
+ + {/* Leaderboard Table (Replaced with ag-Grid) */} +
+ +
+
+ ); +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = OverallLeaderboard; +Component.displayName = 'OverallLeaderboard'; + +export default OverallLeaderboard; diff --git a/src/pages/leaderboard/subcomponents/OverallLeaderboardWrapper.tsx b/src/pages/leaderboard/subcomponents/OverallLeaderboardWrapper.tsx new file mode 100644 index 0000000000..d81e9f695f --- /dev/null +++ b/src/pages/leaderboard/subcomponents/OverallLeaderboardWrapper.tsx @@ -0,0 +1,37 @@ +import React, { useEffect, useState } from 'react'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; + +import NotFound from '../../notFound/NotFound'; +import OverallLeaderboard from './OverallLeaderboard'; + +const OverallLeaderboardWrapper: React.FC = () => { + const enableOverallLeaderboard = useTypedSelector( + store => store.session.enableOverallLeaderboard + ); + + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + // Wait for enableOverallLeaderboard to be resolved or loaded + if (enableOverallLeaderboard !== undefined) { + setIsReady(true); + } + }, [enableOverallLeaderboard]); + + if (!isReady || !enableOverallLeaderboard) { + return ; + } + + if (!enableOverallLeaderboard) { + return ; + } else { + return ; + } +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = OverallLeaderboardWrapper; +Component.displayName = 'OverallLeaderboardWrapper'; + +export default OverallLeaderboardWrapper; diff --git a/src/pages/localStorage.ts b/src/pages/localStorage.ts index 0f9cd6d2d3..a8e6e62164 100644 --- a/src/pages/localStorage.ts +++ b/src/pages/localStorage.ts @@ -67,6 +67,10 @@ export const saveState = (state: OverallState) => { viewable: state.session.viewable, enableGame: state.session.enableGame, enableAchievements: state.session.enableAchievements, + enableOverallLeaderboard: state.session.enableContestLeaderboard, + enableContestLeaderboard: state.session.enableOverallLeaderboard, + topLeaderboardDisplay: state.session.topLeaderboardDisplay, + topContestLeaderboardDisplay: state.session.topContestLeaderboardDisplay, enableSourcecast: state.session.enableSourcecast, enableStories: state.session.enableStories, moduleHelpText: state.session.moduleHelpText, diff --git a/src/styles/Leaderboard.scss b/src/styles/Leaderboard.scss new file mode 100644 index 0000000000..7292a78883 --- /dev/null +++ b/src/styles/Leaderboard.scss @@ -0,0 +1,312 @@ +$gold-color: #ffd700; +$silver-color: #919191; +$bronze-color: #cd7f32; +$primary-color: white; +$secondary-color: rgba(0, 0, 0, 0.6); +$border-color: white; +$shadow-color: rgba(0, 0, 0, 0.1); +$highlight-color: rgba(255, 255, 255, 0.25); +$xp-color: rgb(31, 147, 255); + +.leaderboard-container { + background: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; +} + +// Top 3 Players +.top-three-podium { + display: flex; + justify-content: center; + align-items: flex-end; + gap: 20px; + margin-bottom: 20px; + margin-top: 100px; + transition: all 0.3s ease-in-out; + + .top-player { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + transition: transform 0.3s ease-in-out; + + .player-name { + font-size: 16px; + font-weight: bold; + color: white; + text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5); + position: absolute; + overflow: hidden; + top: -50px; + text-align: center; + width: 120px; + transition: + transform 0.3s ease-in-out, + opacity 0.3s ease-in-out; + } + + .player-bar { + background: $primary-color; + border-radius: 10px; + width: 100px; + text-align: center; + padding: 10px 0; + box-shadow: 0 4px 6px $shadow-color; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transition: + transform 0.3s ease-in-out, + box-shadow 0.3s ease-in-out; + + &:hover { + transform: scale(1.1); + } + &:hover + .player-name { + transform: scale(1.1); + } + } + + .player-rank { + font-size: 24px; + font-weight: bold; + text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.85); + margin-bottom: 5px; + transition: transform 0.3s ease-in-out; + } + + .player-xp { + font-size: 14px; + color: $xp-color; + font-weight: normal; + text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 0.3s ease-in-out; + } + + // Different heights for rankings + &.second .player-bar { + height: 120px; + } + &.first .player-bar { + height: 150px; + } + &.third .player-bar { + height: 100px; + } + + &.first { + color: $gold-color; + } + &.second { + color: $silver-color; + } + &.third { + color: $bronze-color; + } + } + + // Center 1st place + .first { + order: 2; + } + .second { + order: 1; + } + .third { + order: 3; + } + + // Hover and active effects for player bars + &:hover .top-player { + transform: scale(1.05); + } + .top-player:hover .player-name { + opacity: 0.8; + } +} + +// Dropdown +.buttons-container { + width: 100%; + max-width: 1200px; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + .dropdown { + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 5px; + max-width: 500px; + width: auto; //250px; + text-align: left; + cursor: pointer; + } +} + +// export as csv button +.export-button { + background-color: #4caf50; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + border-radius: 5px; + transition: background-color 0.3s ease; + align-self: flex-end; +} + +.export-button:hover { + background-color: #45a049; +} + +.export-button:focus { + outline: none; +} + +/* General ag-Grid customization */ +.ag-theme-alpine .ag-header-cell[col-id='name'] .ag-header-cell-label { + text-align: left !important; + border-left: none !important; + justify-content: flex-start !important; + align-items: flex-start !important; +} + +.ag-theme-alpine .ag-header-cell:last-child .ag-header-cell-resize { + display: none; /* Hide the resize handle after the last column */ +} + +.ag-theme-alpine .ag-root-wrapper { + width: 1200px; + background-color: transparent; + border-radius: 3px; + overflow: hidden; + color: white; +} + +.ag-theme-alpine .ag-header { + background-color: $highlight-color; + border-bottom: 1px solid $border-color; + font-size: 15px; +} + +.ag-theme-alpine .ag-header-cell { + padding: 10px; + font-weight: bold; + color: white; + text-align: center; +} + +.ag-theme-alpine .ag-header-cell-label { + text-align: center; + justify-content: center; +} + +.ag-theme-alpine .ag-row { + background-color: rgba(255, 255, 255, 0.05); + color: white; + font-size: 15px; + text-shadow: 1px 1px #000000; +} + +.ag-theme-alpine .ag-cell { + padding: 10px; + text-align: center; + border: 1px solid $border-color; + background-color: rgba(0, 0, 0, 0.55); +} + +.ag-theme-alpine .ag-cell:nth-child(2) { + border-right: none; +} + +.ag-theme-alpine .ag-cell:nth-child(3) { + text-align: left; + border-left: none; +} + +/* Avatar styling */ +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +} + +/* Pagination styling */ +.ag-theme-alpine .ag-paging-panel .ag-picker-field-display { + color: black; +} +.ag-theme-alpine .ag-paging-panel { + display: flex; + gap: 10px; + margin-top: 20px; + color: white; + background-color: rgba(255, 255, 255, 0.05); +} + +.ag-theme-alpine .ag-paging-panel .ag-paging-button { + padding: 8px 12px; + border: none; + background: rgba(255, 255, 255, 1); + color: white; + border-radius: 5px; + cursor: pointer; + transition: 0.3s; +} + +.ag-theme-alpine .ag-paging-panel .ag-paging-button:hover { + background: rgba(255, 255, 255, 0.4); +} + +.ag-theme-alpine .ag-paging-panel .ag-paging-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ag-theme-alpine .ag-paging-panel .ag-paging-button.ag-active { + background: white; + color: black; + font-weight: bold; +} + +.table-gap { + margin: 50px 0; +} + +h2 { + font-size: 24px; + margin-bottom: 10px; + text-align: center; + color: white; +} + +@media (max-width: 768px) { + // small screen changes + .top-three { + flex-direction: column; + align-items: center; + } + + .top-player { + width: 80%; + max-width: 200px; + } + + .dropdown { + width: 80%; + max-width: 300px; + } + + .leaderboard-table { + width: 100%; + } +}