Add ettempts in contests

This commit is contained in:
Виталий Лавшонок
2025-12-03 21:15:42 +03:00
parent 8f337e6f7b
commit 358c7def78
14 changed files with 377 additions and 260 deletions

View File

@@ -9,6 +9,7 @@ import { fetchMissionById, setMissionsStatus } from '../redux/slices/missions';
import Header from '../views/mission/statement/Header'; import Header from '../views/mission/statement/Header';
import MissionSubmissions from '../views/mission/statement/MissionSubmissions'; import MissionSubmissions from '../views/mission/statement/MissionSubmissions';
import { useQuery } from '../hooks/useQuery'; import { useQuery } from '../hooks/useQuery';
import { fetchMyAttemptsInContest } from '../redux/slices/contests';
const Mission = () => { const Mission = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -20,6 +21,10 @@ const Mission = () => {
const missionStatus = useAppSelector( const missionStatus = useAppSelector(
(state) => state.missions.statuses.fetchById, (state) => state.missions.statuses.fetchById,
); );
const attempt = useAppSelector(
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
);
const missionIdNumber = Number(missionId); const missionIdNumber = Number(missionId);
const query = useQuery(); const query = useQuery();
@@ -44,6 +49,9 @@ const Mission = () => {
if (pollingRef.current) return; if (pollingRef.current) return;
pollingRef.current = setInterval(async () => { pollingRef.current = setInterval(async () => {
if (contestId) {
dispatch(fetchMyAttemptsInContest(contestId));
}
dispatch(fetchMySubmitsByMission(missionIdNumber)); dispatch(fetchMySubmitsByMission(missionIdNumber));
const hasWaiting = submissionsRef.current.some( const hasWaiting = submissionsRef.current.some(
@@ -63,6 +71,12 @@ const Mission = () => {
}, 5000); // 10 секунд }, 5000); // 10 секунд
}; };
useEffect(() => {
if (contestId) {
dispatch(fetchMyAttemptsInContest(contestId));
}
}, [contestId]);
useEffect(() => { useEffect(() => {
dispatch(fetchMissionById(missionIdNumber)); dispatch(fetchMissionById(missionIdNumber));
dispatch(fetchMySubmitsByMission(missionIdNumber)); dispatch(fetchMySubmitsByMission(missionIdNumber));
@@ -194,7 +208,8 @@ const Mission = () => {
language: language, language: language,
languageVersion: 'latest', languageVersion: 'latest',
sourceCode: code, sourceCode: code,
contestId: contestId, contestAttemptId:
attempt?.attemptId,
}), }),
).unwrap(); ).unwrap();
dispatch( dispatch(

View File

@@ -75,6 +75,7 @@ export interface Attempt {
startedAt: string; startedAt: string;
expiresAt: string; expiresAt: string;
finished: boolean; finished: boolean;
submissions?: Submission[];
results?: any[]; results?: any[];
} }
@@ -203,12 +204,11 @@ interface ContestsState {
}; };
fetchParticipating: { fetchParticipating: {
contests: Contest[], contests: Contest[];
hasNextPage: boolean, hasNextPage: boolean;
status: Status, status: Status;
error?: string, error?: string;
}; };
} }
const emptyContest: Contest = { const emptyContest: Contest = {
@@ -253,12 +253,11 @@ const initialState: ContestsState = {
checkRegistration: { registered: false, status: 'idle' }, checkRegistration: { registered: false, status: 'idle' },
fetchUpcomingEligible: { contests: [], status: 'idle' }, fetchUpcomingEligible: { contests: [], status: 'idle' },
fetchParticipating: { fetchParticipating: {
contests: [], contests: [],
hasNextPage: false, hasNextPage: false,
status: 'idle', status: 'idle',
error: undefined, error: undefined,
}, },
}; };
// ===================== // =====================
@@ -277,19 +276,18 @@ export const fetchParticipatingContests = createAsyncThunk(
const { page = 0, pageSize = 10 } = params; const { page = 0, pageSize = 10 } = params;
const response = await axios.get<ContestsResponse>( const response = await axios.get<ContestsResponse>(
'/contests/participating', '/contests/participating',
{ params: { page, pageSize } } { params: { page, pageSize } },
); );
return response.data; return response.data;
} catch (err: any) { } catch (err: any) {
return rejectWithValue( return rejectWithValue(
err.response?.data?.message || 'Failed to fetch participating contests' err.response?.data?.message ||
'Failed to fetch participating contests',
); );
} }
} },
); );
export const fetchMySubmissions = createAsyncThunk( export const fetchMySubmissions = createAsyncThunk(
'contests/fetchMySubmissions', 'contests/fetchMySubmissions',
async (contestId: number, { rejectWithValue }) => { async (contestId: number, { rejectWithValue }) => {
@@ -928,25 +926,27 @@ const contestsSlice = createSlice({
}, },
); );
builder.addCase(fetchParticipatingContests.pending, (state) => { builder.addCase(fetchParticipatingContests.pending, (state) => {
state.fetchParticipating.status = 'loading'; state.fetchParticipating.status = 'loading';
}); });
builder.addCase( builder.addCase(
fetchParticipatingContests.fulfilled, fetchParticipatingContests.fulfilled,
(state, action: PayloadAction<ContestsResponse>) => { (state, action: PayloadAction<ContestsResponse>) => {
state.fetchParticipating.status = 'successful'; state.fetchParticipating.status = 'successful';
state.fetchParticipating.contests = action.payload.contests; state.fetchParticipating.contests = action.payload.contests;
state.fetchParticipating.hasNextPage = action.payload.hasNextPage; state.fetchParticipating.hasNextPage =
} action.payload.hasNextPage;
); },
);
builder.addCase(fetchParticipatingContests.rejected, (state, action: any) => {
state.fetchParticipating.status = 'failed';
state.fetchParticipating.error = action.payload;
});
builder.addCase(
fetchParticipatingContests.rejected,
(state, action: any) => {
state.fetchParticipating.status = 'failed';
state.fetchParticipating.error = action.payload;
},
);
}, },
}); });

View File

@@ -8,7 +8,7 @@ export interface Submit {
language: string; language: string;
languageVersion: string; languageVersion: string;
sourceCode: string; sourceCode: string;
contestId?: number; contestAttemptId?: number;
} }
export interface Solution { export interface Solution {

View File

@@ -1,8 +1,7 @@
import { cn } from '../../../../lib/cn'; import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Edit } from '../../../../assets/icons/input'; import { Edit } from '../../../../assets/icons/input';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { useAppSelector } from '../../../../redux/hooks';
import { deleteMission } from '../../../../redux/slices/missions';
export interface MissionItemProps { export interface MissionItemProps {
id: number; id: number;
@@ -43,7 +42,6 @@ const MissionItem: React.FC<MissionItemProps> = ({
setDeleteModalActive, setDeleteModalActive,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch();
const difficultyItems = ['Easy', 'Medium', 'Hard']; const difficultyItems = ['Easy', 'Medium', 'Hard'];
const difficultyString = const difficultyString =
difficultyItems[Math.min(Math.max(0, difficulty - 1), 2)]; difficultyItems[Math.min(Math.max(0, difficulty - 1), 2)];

View File

@@ -8,8 +8,6 @@ import { loginUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn"; // import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store'; import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from '../../../assets/icons/auth'; import { Balloon } from '../../../assets/icons/auth';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { googleLogo } from '../../../assets/icons/input';
const Login = () => { const Login = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();

View File

@@ -9,9 +9,7 @@ import { registerUser } from '../../../redux/slices/auth';
import { setMenuActivePage } from '../../../redux/slices/store'; import { setMenuActivePage } from '../../../redux/slices/store';
import { Balloon } from '../../../assets/icons/auth'; import { Balloon } from '../../../assets/icons/auth';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { SecondaryButton } from '../../../components/button/SecondaryButton';
import { Checkbox } from '../../../components/checkbox/Checkbox'; import { Checkbox } from '../../../components/checkbox/Checkbox';
import { googleLogo } from '../../../assets/icons/input';
function isValidEmail(email: string): boolean { function isValidEmail(email: string): boolean {
const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

View File

@@ -2,8 +2,6 @@ import { FC, useEffect, useState } from 'react';
import MissionItem from './MissionItem'; import MissionItem from './MissionItem';
import { import {
Contest, Contest,
fetchContestById,
fetchContests,
fetchMyAttemptsInContest, fetchMyAttemptsInContest,
fetchMySubmissions, fetchMySubmissions,
setContestStatus, setContestStatus,
@@ -24,52 +22,54 @@ interface ContestMissionsProps {
contest?: Contest; contest?: Contest;
} }
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => { const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { submissions, status } = useAppSelector( const { status } = useAppSelector(
(state) => state.contests.fetchMySubmissions, (state) => state.contests.fetchMySubmissions,
); );
const attempts = useAppSelector((state) => state.contests.fetchMyAttemptsInContest.attempts); const attempts = useAppSelector(
(state) => state.contests.fetchMyAttemptsInContest.attempts,
);
const submissions = useAppSelector(
(state) =>
state.contests.fetchMyAttemptsInContest.attempts[0]?.submissions,
);
const [attemptsStarted, setAttemptsStarted] = useState<boolean>(false); const [attemptsStarted, setAttemptsStarted] = useState<boolean>(false);
const [time, setTime] = useState(0); const [time, setTime] = useState(0);
useEffect(() => { useEffect(() => {
const calc = (time: string) => { const calc = (time: string) => {
return time != "" && new Date() <= new Date(time); return time != '' && new Date() <= new Date(time);
} };
if (attempts.length && calc(attempts[0].expiresAt)){ if (attempts.length && calc(attempts[0].expiresAt)) {
setAttemptsStarted(true);
setAttemptsStarted(true);
const diffMs = new Date(attempts[0].expiresAt).getTime() - new Date().getTime();
const seconds = Math.floor((diffMs / 1000));
setTime(seconds) const diffMs =
new Date(attempts[0].expiresAt).getTime() -
new Date().getTime();
const seconds = Math.floor(diffMs / 1000);
setTime(seconds);
const interval = setInterval(() => { const interval = setInterval(() => {
setTime((t) => { setTime((t) => {
if (t <= 1) { if (t <= 1) {
clearInterval(interval); // остановка таймера clearInterval(interval); // остановка таймера
setAttemptsStarted(false); // можно закрыть попытку или уведомить пользователя setAttemptsStarted(false); // можно закрыть попытку или уведомить пользователя
return 0; return 0;
} }
return t - 1; return t - 1;
}); });
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
} } else setAttemptsStarted(false);
else }, [attempts]);
setAttemptsStarted(false);
}, [attempts])
useEffect(() => { useEffect(() => {
if (contest) dispatch(fetchMySubmissions(contest.id)); if (contest) dispatch(fetchMySubmissions(contest.id));
@@ -88,7 +88,7 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
} }
const solvedCount = (contest.missions ?? []).filter((mission) => const solvedCount = (contest.missions ?? []).filter((mission) =>
submissions.some( submissions?.some(
(s) => (s) =>
s.solution.missionId === mission.id && s.solution.missionId === mission.id &&
s.solution.status === 'Accepted: All tests passed', s.solution.status === 'Accepted: All tests passed',
@@ -97,11 +97,9 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
const totalCount = contest.missions?.length ?? 0; const totalCount = contest.missions?.length ?? 0;
// форматирование: mm:ss
// форматирование: mm:ss const minutes = String(Math.floor(time / 60)).padStart(2, '0');
const minutes = String(Math.floor(time / 60)).padStart(2, "0"); const seconds = String(time % 60).padStart(2, '0');
const seconds = String(time % 60).padStart(2, "0");
return ( return (
<div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] gap-[20px]"> <div className=" h-screen grid grid-rows-[74px,40px,1fr] p-[20px] gap-[20px]">
@@ -123,43 +121,60 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
</span> </span>
</div> </div>
<div className="text-liquid-light font-bold text-[18px]"> <div className="text-liquid-light font-bold text-[18px]">
{attemptsStarted {attemptsStarted
? `${minutes}:${seconds}` ? `${minutes}:${seconds}`
: `Длительность попытки: ${contest.attemptDurationMinutes ?? 0} минут`} : `Длительность попытки: ${
contest.attemptDurationMinutes ?? 0
} минут. Осталось попыток ${
(contest.maxAttempts ?? 0) -
(attempts?.length ?? 0)
}/${contest.maxAttempts ?? 0}`}
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div> <div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
<div className='flex gap-[20px]'> <div className="flex gap-[20px]">
{ attempts.length == 0 || !attemptsStarted ? <PrimaryButton {attempts.length == 0 || !attemptsStarted ? (
onClick={() => { <PrimaryButton
dispatch(startContestAttempt(contest.id)).unwrap().then(() => onClick={() => {
{ dispatch(startContestAttempt(contest.id))
dispatch(fetchMyAttemptsInContest(contest.id)); .unwrap()
} .then(() => {
); dispatch(
}} fetchMyAttemptsInContest(
text="Начать попытку" contest.id,
/> : <></> ),
} <PrimaryButton );
onClick={() => { });
navigate(`/contest/${contest.id}/submissions`); }}
}} text="Начать попытку"
text="Мои посылки" disabled={
/></div> (contest.maxAttempts ?? 0) -
(attempts?.length ?? 0) <=
0
}
/>
) : (
<></>
)}{' '}
<PrimaryButton
onClick={() => {
navigate(`/contest/${contest.id}/submissions`);
}}
text="Мои посылки"
/>
</div>
</div> </div>
<div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]"> <div className="h-full min-h-0 overflow-y-scroll medium-scrollbar flex flex-col gap-[20px]">
<div className="w-full"> <div className="w-full">
{(contest.missions ?? []).map((v, i) => { {(contest.missions ?? []).map((v, i) => {
const missionSubmissions = submissions.filter( const missionSubmissions = submissions?.filter(
(s) => s.solution.missionId === v.id, (s) => s.solution.missionId === v.id,
); );
const hasSuccess = missionSubmissions.some( const hasSuccess = missionSubmissions?.some(
(s) => (s) =>
s.solution.status == s.solution.status ==
'Accepted: All tests passed', 'Accepted: All tests passed',
@@ -167,7 +182,8 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
const status = hasSuccess const status = hasSuccess
? 'success' ? 'success'
: missionSubmissions.length > 0 : missionSubmissions?.length &&
missionSubmissions.length > 0
? 'error' ? 'error'
: undefined; : undefined;

View File

@@ -1,130 +1,81 @@
import SubmissionItem from "./SubmissionItem"; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { FC, useEffect } from 'react';
import { FC, useEffect } from "react"; import { Contest, fetchMySubmissions } from '../../../redux/slices/contests';
import { import { arrowLeft } from '../../../assets/icons/header';
Contest, import { useNavigate } from 'react-router-dom';
fetchMySubmissions, import SubmissionsBlock from './SubmissionsBlock';
setContestStatus,
} from "../../../redux/slices/contests";
import { arrowLeft } from "../../../assets/icons/header";
import { useNavigate } from "react-router-dom";
export interface Mission { export interface Mission {
id: number; id: number;
authorId: number; authorId: number;
name: string; name: string;
difficulty: "Easy" | "Medium" | "Hard"; difficulty: 'Easy' | 'Medium' | 'Hard';
tags: string[]; tags: string[];
timeLimit: number; timeLimit: number;
memoryLimit: number; memoryLimit: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
interface SubmissionsProps { interface SubmissionsProps {
contest: Contest; contest: Contest;
} }
const Submissions: FC<SubmissionsProps> = ({ contest }) => { const Submissions: FC<SubmissionsProps> = ({ contest }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const { submissions, status } = useAppSelector( const attempts = useAppSelector(
(state) => state.contests.fetchMySubmissions (state) => state.contests.fetchMyAttemptsInContest.attempts,
); );
const submissions = useAppSelector(
(state) =>
state.contests.fetchMyAttemptsInContest.attempts[0]?.submissions,
);
useEffect(() => {
if (contest && contest.id) dispatch(fetchMySubmissions(contest.id));
}, [contest]);
useEffect(() => { const solvedCount = (contest.missions ?? []).filter((mission) =>
if (contest && contest.id) dispatch(fetchMySubmissions(contest.id)); submissions?.some(
}, [contest]); (s) =>
s.solution.missionId === mission.id &&
s.solution.status === 'Accepted: All tests passed',
),
).length;
useEffect(() => { const totalCount = contest.missions?.length ?? 0;
if (status == "successful") {
dispatch(setContestStatus({ key: "fetchMySubmissions", status: "idle" }));
}
}, [status]);
const checkStatus = (status: string) => { return (
if (status == "IncorrectAnswer") return "wronganswer"; <div className="h-full w-[calc(100%+250px)] box-border overflow-y-scroll overflow-x-hidden thin-scrollbar p-[20px] flex flex-col gap-[20px]">
if (status == "TimeLimitError") return "timelimit"; <div className="">
return undefined; <div className="h-[50px] text-[40px] text-liquid-white font-bold">
}; {contest.name}
</div>
const solvedCount = (contest.missions ?? []).filter((mission) => <div className="flex justify-between h-[24px] items-center gap-[10px]">
submissions.some( <div className="flex items-center">
(s) => <img
s.solution.missionId === mission.id && src={arrowLeft}
s.solution.status === "Accepted: All tests passed" className="cursor-pointer"
) onClick={() => {
).length; navigate(`/contest/${contest.id}`);
}}
const totalCount = contest.missions?.length ?? 0; />
<span className="text-liquid-light font-bold text-[18px]">
return ( Контест #{contest.id}
<div className="h-full w-[calc(100%+250px)] box-border overflow-y-scroll overflow-x-hidden thin-scrollbar p-[20px] flex flex-col gap-[20px]"> </span>
<div className=""> </div>
<div className="h-[50px] text-[40px] text-liquid-white font-bold"> <div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
{contest.name} </div>
</div>
<div className="h-full overflow-y-scroll medium-scrollbar pr-[20px]">
{attempts?.map((v, i) => (
<SubmissionsBlock key={i} attempt={v} />
))}
</div>
</div> </div>
<div className="flex justify-between h-[24px] items-center gap-[10px]"> );
<div className="flex items-center">
<img
src={arrowLeft}
className="cursor-pointer"
onClick={() => {
navigate(`/contest/${contest.id}`);
}}
/>
<span className="text-liquid-light font-bold text-[18px]">
Контест #{contest.id}
</span>
</div>
<div className="text-liquid-white text-[16px] font-bold">{`${solvedCount}/${totalCount} Решено`}</div>
</div>
</div>
<div>
<div className="grid grid-cols-7 text-center items-center h-[43px] mb-[10px] text-[16px] font-bold text-liquid-white">
<div>Посылка</div>
<div>Когда</div>
<div>Задача</div>
<div>Язык</div>
<div>Вердикт</div>
<div>Время</div>
<div>Память</div>
</div>
{!submissions || submissions.length == 0 ? (
<div className="text-liquid-brightmain text-[16px] font-medium text-center mt-[50px]">Вы еще ничего не отсылали</div>
) : (
<>
{submissions.map((v, i) => (
<SubmissionItem
key={i}
id={v.id ?? 0}
datetime={v.solution.time}
missionId={v.solution.missionId}
language={v.solution.language}
verdict={
v.solution.testerMessage?.includes("Compilation failed")
? "Compilation failed"
: v.solution.testerMessage
}
duration={1000}
memory={256 * 1024 * 1024}
type={i % 2 ? "second" : "first"}
status={
v.solution.testerMessage == "All tests passed"
? "success"
: checkStatus(v.solution.testerErrorCode)
}
/>
))}
</>
)}
</div>
</div>
);
}; };
export default Submissions; export default Submissions;

View File

@@ -0,0 +1,75 @@
import SubmissionItem from './SubmissionItem';
import { FC } from 'react';
import { Attempt } from '../../../redux/slices/contests';
interface SubmissionsBlockProps {
attempt: Attempt;
}
const SubmissionsBlock: FC<SubmissionsBlockProps> = ({ attempt }) => {
const submissions = attempt?.submissions;
const isFinished = new Date(attempt.expiresAt) < new Date();
const checkStatus = (status: string) => {
if (status == 'IncorrectAnswer') return 'wronganswer';
if (status == 'TimeLimitError') return 'timelimit';
return undefined;
};
return (
<div className="mb-[50px]">
<div className="flex items-center justify-center text-liquid-white font-bold text-[20px]">{`Попытка #${attempt.attemptId}`}</div>
{!submissions || submissions.length == 0 ? (
<></>
) : (
<div className="grid grid-cols-7 text-center items-center h-[43px] mb-[10px] text-[16px] font-bold text-liquid-white">
<div>Посылка</div>
<div>Когда</div>
<div>Задача</div>
<div>Язык</div>
<div>Вердикт</div>
<div>Время</div>
<div>Память</div>
</div>
)}
{!submissions || submissions.length == 0 ? (
<div className="text-liquid-brightmain text-[16px] font-medium text-center">
{isFinished
? 'Вы ничего не посылали в этот сеанс'
: 'Вы еще ничего не отсылали'}
</div>
) : (
<>
{submissions.map((v, i) => (
<SubmissionItem
key={i}
id={v.id ?? 0}
datetime={v.solution.time}
missionId={v.solution.missionId}
language={v.solution.language}
verdict={
v.solution.testerMessage?.includes(
'Compilation failed',
)
? 'Compilation failed'
: v.solution.testerMessage
}
duration={1000}
memory={256 * 1024 * 1024}
type={i % 2 ? 'second' : 'first'}
status={
v.solution.testerMessage == 'All tests passed'
? 'success'
: checkStatus(v.solution.testerErrorCode)
}
/>
))}
</>
)}
<div className="h-[1px] bg-liquid-lighter mt-[50px]"></div>
</div>
);
};
export default SubmissionsBlock;

View File

@@ -3,12 +3,12 @@ import { Account } from '../../../assets/icons/auth';
import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { ReverseButton } from '../../../components/button/ReverseButton'; import { ReverseButton } from '../../../components/button/ReverseButton';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toastSuccess, toastWarning } from '../../../lib/toastNotification'; import { toastWarning } from '../../../lib/toastNotification';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { addOrUpdateContestMember } from '../../../redux/slices/contests'; import { addOrUpdateContestMember } from '../../../redux/slices/contests';
export type Role = "None" | "Participant" | "Organizer"; export type Role = 'None' | 'Participant' | 'Organizer';
export interface ContestItemProps { export interface ContestItemProps {
id: number; id: number;
@@ -67,31 +67,29 @@ const ContestItem: React.FC<ContestItemProps> = ({
const waitTime = new Date(startAt).getTime() - now.getTime(); const waitTime = new Date(startAt).getTime() - now.getTime();
const [myRole, setMyRole] = useState<Role>("None"); const [myRole, setMyRole] = useState<Role>('None');
const userId = useAppSelector((state) => state.auth.id); const userId = useAppSelector((state) => state.auth.id);
const {contests: contestsRegistered} = useAppSelector((state) => state.contests.fetchParticipating); const { contests: contestsRegistered } = useAppSelector(
const {contests: contestsMy} = useAppSelector((state) => state.contests.fetchMyContests); (state) => state.contests.fetchParticipating,
);
const { contests: contestsMy } = useAppSelector(
(state) => state.contests.fetchMyContests,
);
useEffect(() => { useEffect(() => {
if (!contestsRegistered || contestsRegistered.length === 0) { if (!contestsRegistered || contestsRegistered.length === 0) {
setMyRole("None"); setMyRole('None');
return; return;
} }
const reg = contestsRegistered.find(c => c.id === id);
const my = contestsMy.find(c => c.id === id);
if (my)
setMyRole("Organizer");
else if (reg)
setMyRole("Participant");
else
setMyRole("None");
}, [contestsRegistered])
const reg = contestsRegistered.find((c) => c.id === id);
const my = contestsMy.find((c) => c.id === id);
if (my) setMyRole('Organizer');
else if (reg) setMyRole('Participant');
else setMyRole('None');
}, [contestsRegistered]);
return ( return (
<div <div
@@ -104,9 +102,9 @@ const ContestItem: React.FC<ContestItemProps> = ({
: ' bg-liquid-background', : ' bg-liquid-background',
)} )}
onClick={() => { onClick={() => {
if (myRole == 'None'){ if (myRole == 'None') {
toastWarning("Зарегистрируйтесь на контест"); toastWarning('Зарегистрируйтесь на контест');
return; return;
} }
navigate(`/contest/${id}`); navigate(`/contest/${id}`);
}} }}
@@ -133,15 +131,30 @@ const ContestItem: React.FC<ContestItemProps> = ({
{myRole == 'None' ? ( {myRole == 'None' ? (
<> <>
{' '} {' '}
<PrimaryButton onClick={() => { <PrimaryButton
dispatch(addOrUpdateContestMember({contestId: id, member: {userId: Number(userId), role:"Participant"}})) onClick={() => {
}} text="Регистрация" /> dispatch(
addOrUpdateContestMember({
contestId: id,
member: {
userId: Number(userId),
role: 'Participant',
},
}),
);
}}
text="Регистрация"
/>
</> </>
) : ( ) : (
<> <>
{' '} {' '}
<ReverseButton onClick={() => { <ReverseButton
navigate(`/contest/${id}`);}} text="Войти" /> onClick={() => {
navigate(`/contest/${id}`);
}}
text="Войти"
/>
</> </>
)} )}
</div> </div>

View File

@@ -3,7 +3,6 @@ import { cn } from '../../../lib/cn';
import { ChevroneDown } from '../../../assets/icons/groups'; import { ChevroneDown } from '../../../assets/icons/groups';
import ContestItem from './ContestItem'; import ContestItem from './ContestItem';
import { Contest } from '../../../redux/slices/contests'; import { Contest } from '../../../redux/slices/contests';
import { useAppSelector } from '../../../redux/hooks';
interface ContestsBlockProps { interface ContestsBlockProps {
contests: Contest[]; contests: Contest[];
@@ -58,8 +57,12 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
name={v.name} name={v.name}
startAt={v.startsAt ?? new Date().toString()} startAt={v.startsAt ?? new Date().toString()}
duration={ duration={
new Date(v.endsAt ?? new Date().toString()).getTime() - new Date(
new Date(v.startsAt ?? new Date().toString()).getTime() v.endsAt ?? new Date().toString(),
).getTime() -
new Date(
v.startsAt ?? new Date().toString(),
).getTime()
} }
members={v.members?.length ?? 0} members={v.members?.length ?? 0}
type={i % 2 ? 'second' : 'first'} type={i % 2 ? 'second' : 'first'}

View File

@@ -1,22 +1,6 @@
import {
FilterDropDown,
FilterItem,
} from '../../../components/drop-down-list/Filter';
import { SorterDropDown } from '../../../components/drop-down-list/Sorter';
import { SearchInput } from '../../../components/input/SearchInput'; import { SearchInput } from '../../../components/input/SearchInput';
const Filters = () => { const Filters = () => {
const items: FilterItem[] = [
{ text: 'React', value: 'react' },
{ text: 'Vue', value: 'vue' },
{ text: 'Angular', value: 'angular' },
{ text: 'Svelte', value: 'svelte' },
{ text: 'Next.js', value: 'next' },
{ text: 'Nuxt', value: 'nuxt' },
{ text: 'Solid', value: 'solid' },
{ text: 'Qwik', value: 'qwik' },
];
return ( return (
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center"> <div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
<SearchInput onChange={() => {}} placeholder="Поиск группы" /> <SearchInput onChange={() => {}} placeholder="Поиск группы" />

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { import {
chevroneLeft, chevroneLeft,
chevroneRight, chevroneRight,
@@ -6,6 +6,9 @@ import {
} from '../../../assets/icons/header'; } from '../../../assets/icons/header';
import { Logo } from '../../../assets/logos'; import { Logo } from '../../../assets/logos';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useQuery } from '../../../hooks/useQuery';
import { fetchMyAttemptsInContest } from '../../../redux/slices/contests';
interface HeaderProps { interface HeaderProps {
missionId: number; missionId: number;
@@ -14,6 +17,57 @@ interface HeaderProps {
const Header: React.FC<HeaderProps> = ({ missionId, back }) => { const Header: React.FC<HeaderProps> = ({ missionId, back }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch();
const query = useQuery();
const contestId = Number(query.get('contestId') ?? undefined);
const attempt = useAppSelector(
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
);
const [time, setTime] = useState(0);
useEffect(() => {
if (!contestId) return;
const calc = (time: string) => {
return time != '' && new Date() <= new Date(time);
};
if (attempt) {
if (!calc(attempt.expiresAt)) {
navigate('/home/contests');
}
const diffMs =
new Date(attempt.expiresAt).getTime() - new Date().getTime();
const seconds = Math.floor(diffMs / 1000);
setTime(seconds);
const interval = setInterval(() => {
setTime((t) => {
if (t <= 1) {
clearInterval(interval);
navigate('/home/contests');
return 0;
}
return t - 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [attempt]);
useEffect(() => {
if (contestId) {
dispatch(fetchMyAttemptsInContest(contestId));
}
}, [contestId]);
const minutes = String(Math.floor(time / 60)).padStart(2, '0');
const seconds = String(time % 60).padStart(2, '0');
return ( return (
<header className="w-full h-[60px] flex items-center px-4 gap-[20px]"> <header className="w-full h-[60px] flex items-center px-4 gap-[20px]">
<img <img
@@ -59,6 +113,9 @@ const Header: React.FC<HeaderProps> = ({ missionId, back }) => {
}} }}
/> />
</div> </div>
{!!contestId && !!attempt && (
<div className="">{`${minutes}:${seconds}`}</div>
)}
</header> </header>
); );
}; };

View File

@@ -19,9 +19,16 @@ interface MissionSubmissionsProps {
contestId?: number; contestId?: number;
} }
const MissionSubmissions: FC<MissionSubmissionsProps> = ({ missionId, contestId }) => { const MissionSubmissions: FC<MissionSubmissionsProps> = ({
missionId,
contestId,
}) => {
const submissions = useAppSelector( const submissions = useAppSelector(
(state) => state.submin.submitsById[missionId] || [] (state) => state.submin.submitsById[missionId] || [],
);
const attempt = useAppSelector(
(state) => state.contests.fetchMyAttemptsInContest.attempts[0],
); );
const checkStatus = (status: string) => { const checkStatus = (status: string) => {
@@ -32,7 +39,9 @@ const MissionSubmissions: FC<MissionSubmissionsProps> = ({ missionId, contestId
// Если contestId передан, фильтруем по нему, иначе показываем все // Если contestId передан, фильтруем по нему, иначе показываем все
const filteredSubmissions = contestId const filteredSubmissions = contestId
? submissions.filter((v) => v.contestId === contestId) ? attempt?.submissions?.filter(
(v) => v.solution.missionId == missionId,
) ?? []
: submissions; : submissions;
return ( return (