From cdb55957690b4e3fbe8fffad97946c86f7927001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Tue, 4 Nov 2025 19:33:47 +0300 Subject: [PATCH] contests --- src/components/button/PrimaryButton.tsx | 8 +- src/components/button/ReverseButton.tsx | 8 +- src/components/button/SecondaryButton.tsx | 6 +- src/components/input/DateRangeInput.tsx | 48 +++++ src/pages/Home.tsx | 2 + src/redux/slices/contests.ts | 78 +++++--- src/views/home/contest/Contest.tsx | 45 +++++ src/views/home/contest/MissionItem.tsx | 65 +++++++ src/views/home/contest/Missions.tsx | 42 +++++ src/views/home/contest/Submissions.tsx | 0 src/views/home/contests/ContestItem.tsx | 24 ++- src/views/home/contests/Contests.tsx | 20 +- src/views/home/contests/ContestsBlock.tsx | 1 + src/views/home/contests/ModalCreate.tsx | 191 ++++++++++++++++++++ src/views/home/missions/MissionItem.tsx | 22 +-- src/views/home/missions/Missions.tsx | 1 - src/views/home/missions/ModalCreate.tsx | 15 +- src/views/mission/submission/Submission.tsx | 0 18 files changed, 511 insertions(+), 65 deletions(-) create mode 100644 src/components/input/DateRangeInput.tsx create mode 100644 src/views/home/contest/Contest.tsx create mode 100644 src/views/home/contest/MissionItem.tsx create mode 100644 src/views/home/contest/Missions.tsx create mode 100644 src/views/home/contest/Submissions.tsx create mode 100644 src/views/home/contests/ModalCreate.tsx create mode 100644 src/views/mission/submission/Submission.tsx diff --git a/src/components/button/PrimaryButton.tsx b/src/components/button/PrimaryButton.tsx index d476289..6a9423f 100644 --- a/src/components/button/PrimaryButton.tsx +++ b/src/components/button/PrimaryButton.tsx @@ -5,7 +5,7 @@ interface ButtonProps { disabled?: boolean; text?: string; className?: string; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; children?: React.ReactNode; color?: 'primary' | 'secondary' | 'error' | 'warning' | 'success'; } @@ -60,8 +60,10 @@ export const PrimaryButton: React.FC = ({ '[&:focus-visible+*]:outline-liquid-brightmain', )} disabled={disabled} - onClick={() => { - onClick(); + onClick={( + e: React.MouseEvent, + ) => { + onClick(e); }} /> diff --git a/src/components/button/ReverseButton.tsx b/src/components/button/ReverseButton.tsx index 9002476..ee38a6e 100644 --- a/src/components/button/ReverseButton.tsx +++ b/src/components/button/ReverseButton.tsx @@ -5,7 +5,7 @@ interface ButtonProps { disabled?: boolean; text?: string; className?: string; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; children?: React.ReactNode; } @@ -42,8 +42,10 @@ export const ReverseButton: React.FC = ({ '[&:focus-visible+*]:outline-liquid-brightmain', )} disabled={disabled} - onClick={() => { - onClick(); + onClick={( + e: React.MouseEvent, + ) => { + onClick(e); }} /> diff --git a/src/components/button/SecondaryButton.tsx b/src/components/button/SecondaryButton.tsx index 96be5b9..e71ab94 100644 --- a/src/components/button/SecondaryButton.tsx +++ b/src/components/button/SecondaryButton.tsx @@ -5,7 +5,7 @@ interface ButtonProps { disabled?: boolean; text?: string; className?: string; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; children?: React.ReactNode; } @@ -41,8 +41,8 @@ export const SecondaryButton: React.FC = ({ '[&:focus-visible+*]:outline-liquid-brightmain', )} disabled={disabled} - onClick={() => { - onClick(); + onClick={(e) => { + onClick(e); }} /> diff --git a/src/components/input/DateRangeInput.tsx b/src/components/input/DateRangeInput.tsx new file mode 100644 index 0000000..1a1c732 --- /dev/null +++ b/src/components/input/DateRangeInput.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +interface DateRangeInputProps { + startLabel?: string; + endLabel?: string; + startValue?: string; + endValue?: string; + onChange: (field: 'startsAt' | 'endsAt', value: string) => void; + className?: string; +} + +const DateRangeInput: React.FC = ({ + startLabel = 'Дата начала', + endLabel = 'Дата окончания', + startValue, + endValue, + onChange, + className = '', +}) => { + return ( +
+
+ + onChange('startsAt', e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + /> +
+
+ + onChange('endsAt', e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + /> +
+
+ ); +}; + +export default DateRangeInput; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 1cdb00c..49e9651 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -12,6 +12,7 @@ import Groups from '../views/home/groups/Groups'; import Contests from '../views/home/contests/Contests'; import { PrimaryButton } from '../components/button/PrimaryButton'; import Group from '../views/home/groups/Group'; +import Contest from '../views/home/contest/Contest'; const Home = () => { const name = useAppSelector((state) => state.auth.username); @@ -37,6 +38,7 @@ const Home = () => { } /> } /> } /> + } /> { @@ -119,7 +128,6 @@ export const fetchContestById = createAsyncThunk( }, ); -// Создание нового контеста export const createContest = createAsyncThunk( 'contests/create', async (contestData: CreateContestBody, { rejectWithValue }) => { @@ -148,17 +156,26 @@ const contestsSlice = createSlice({ clearSelectedContest: (state) => { state.selectedContest = null; }, + setContestStatus: ( + state, + action: PayloadAction<{ + key: keyof ContestsState['statuses']; + status: Status; + }>, + ) => { + state.statuses[action.payload.key] = action.payload.status; + }, }, extraReducers: (builder) => { // fetchContests builder.addCase(fetchContests.pending, (state) => { - state.status = 'loading'; + state.statuses.fetchList = 'loading'; state.error = null; }); builder.addCase( fetchContests.fulfilled, (state, action: PayloadAction) => { - state.status = 'successful'; + state.statuses.fetchList = 'successful'; state.contests = action.payload.contests; state.hasNextPage = action.payload.hasNextPage; }, @@ -166,47 +183,47 @@ const contestsSlice = createSlice({ builder.addCase( fetchContests.rejected, (state, action: PayloadAction) => { - state.status = 'failed'; + state.statuses.fetchList = 'failed'; state.error = action.payload; }, ); // fetchContestById builder.addCase(fetchContestById.pending, (state) => { - state.status = 'loading'; + state.statuses.fetchById = 'loading'; state.error = null; }); builder.addCase( fetchContestById.fulfilled, (state, action: PayloadAction) => { - state.status = 'successful'; + state.statuses.fetchById = 'successful'; state.selectedContest = action.payload; }, ); builder.addCase( fetchContestById.rejected, (state, action: PayloadAction) => { - state.status = 'failed'; + state.statuses.fetchById = 'failed'; state.error = action.payload; }, ); // createContest builder.addCase(createContest.pending, (state) => { - state.status = 'loading'; + state.statuses.create = 'loading'; state.error = null; }); builder.addCase( createContest.fulfilled, (state, action: PayloadAction) => { - state.status = 'successful'; + state.statuses.create = 'successful'; state.contests.unshift(action.payload); }, ); builder.addCase( createContest.rejected, (state, action: PayloadAction) => { - state.status = 'failed'; + state.statuses.create = 'failed'; state.error = action.payload; }, ); @@ -216,5 +233,6 @@ const contestsSlice = createSlice({ // ===================== // Экспорты // ===================== -export const { clearSelectedContest } = contestsSlice.actions; + +export const { clearSelectedContest, setContestStatus } = contestsSlice.actions; export const contestsReducer = contestsSlice.reducer; diff --git a/src/views/home/contest/Contest.tsx b/src/views/home/contest/Contest.tsx new file mode 100644 index 0000000..fee728f --- /dev/null +++ b/src/views/home/contest/Contest.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { setMenuActivePage } from '../../../redux/slices/store'; +import { Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { fetchContestById } from '../../../redux/slices/contests'; +import ContestMissions from './Missions'; + +export interface Article { + id: number; + name: string; + tags: string[]; +} + +const Contest = () => { + const { contestId } = useParams<{ contestId: string }>(); + const contestIdNumber = + contestId && /^\d+$/.test(contestId) ? parseInt(contestId, 10) : null; + if (contestIdNumber === null) { + return ; + } + + const dispatch = useAppDispatch(); + const contest = useAppSelector((state) => state.contests.selectedContest); + + useEffect(() => { + dispatch(setMenuActivePage('contest')); + }, []); + + useEffect(() => { + dispatch(fetchContestById(contestIdNumber)); + }, [contestIdNumber]); + + return ( +
+ + } + /> + +
+ ); +}; + +export default Contest; diff --git a/src/views/home/contest/MissionItem.tsx b/src/views/home/contest/MissionItem.tsx new file mode 100644 index 0000000..1648e4e --- /dev/null +++ b/src/views/home/contest/MissionItem.tsx @@ -0,0 +1,65 @@ +import { cn } from '../../../lib/cn'; +import { IconError, IconSuccess } from '../../../assets/icons/missions'; +import { useNavigate } from 'react-router-dom'; + +export interface MissionItemProps { + id: number; + name: string; + timeLimit?: number; + memoryLimit?: number; + type?: 'first' | 'second'; + status?: 'empty' | 'success' | 'error'; +} + +export function formatMilliseconds(ms: number): string { + const rounded = Math.round(ms) / 1000; + const formatted = rounded.toString().replace(/\.?0+$/, ''); + return `${formatted} c`; +} + +export function formatBytesToMB(bytes: number): string { + const megabytes = Math.floor(bytes / (1024 * 1024)); + return `${megabytes} МБ`; +} + +const MissionItem: React.FC = ({ + id, + name, + timeLimit = 1000, + memoryLimit = 256 * 1024 * 1024, + type, + status, +}) => { + const navigate = useNavigate(); + + return ( +
{ + navigate(`/mission/${id}`); + }} + > +
#{id}
+
{name}
+
+ стандартный ввод/вывод {formatMilliseconds(timeLimit)},{' '} + {formatBytesToMB(memoryLimit)} +
+
+ {status == 'error' && } + {status == 'success' && } +
+
+ ); +}; + +export default MissionItem; diff --git a/src/views/home/contest/Missions.tsx b/src/views/home/contest/Missions.tsx new file mode 100644 index 0000000..e4ef5a7 --- /dev/null +++ b/src/views/home/contest/Missions.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react'; +import { useAppDispatch } from '../../../redux/hooks'; +import MissionItem, { MissionItemProps } from './MissionItem'; +import { Contest } from '../../../redux/slices/contests'; + +export interface Article { + id: number; + name: string; + tags: string[]; +} + +interface ContestMissionsProps { + contest: Contest | null; +} + +const ContestMissions: FC = ({ contest }) => { + if (!contest) { + return <>; + } + + return ( +
+
+
+
+ {contest?.name} {contest.id} +
+
+ {contest.missions.map((v, i) => ( + + ))} +
+
+
+ ); +}; + +export default ContestMissions; diff --git a/src/views/home/contest/Submissions.tsx b/src/views/home/contest/Submissions.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/views/home/contests/ContestItem.tsx b/src/views/home/contests/ContestItem.tsx index 153d56d..c2fc6fd 100644 --- a/src/views/home/contests/ContestItem.tsx +++ b/src/views/home/contests/ContestItem.tsx @@ -2,8 +2,10 @@ import { cn } from '../../../lib/cn'; import { Account } from '../../../assets/icons/auth'; import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { ReverseButton } from '../../../components/button/ReverseButton'; +import { useNavigate } from 'react-router-dom'; export interface ContestItemProps { + id: number; name: string; startAt: string; duration: number; @@ -46,6 +48,7 @@ function formatWaitTime(ms: number): string { } const ContestItem: React.FC = ({ + id, name, startAt, duration, @@ -53,6 +56,8 @@ const ContestItem: React.FC = ({ statusRegister, type, }) => { + const navigate = useNavigate(); + const now = new Date(); const waitTime = new Date(startAt).getTime() - now.getTime(); @@ -60,13 +65,16 @@ const ContestItem: React.FC = ({ return (
{ + navigate(`/contest/${id}`); + }} >
{name}
@@ -90,12 +98,22 @@ const ContestItem: React.FC = ({ {statusRegister == 'reg' ? ( <> {' '} - {}} text="Регистрация" /> + { + e.stopPropagation(); + }} + text="Регистрация" + /> ) : ( <> {' '} - {}} text="Вы записаны" /> + { + e.stopPropagation(); + }} + text="Вы записаны" + /> )}
diff --git a/src/views/home/contests/Contests.tsx b/src/views/home/contests/Contests.tsx index 700b65c..d3ad1eb 100644 --- a/src/views/home/contests/Contests.tsx +++ b/src/views/home/contests/Contests.tsx @@ -1,18 +1,21 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { SecondaryButton } from '../../../components/button/SecondaryButton'; import { cn } from '../../../lib/cn'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; import ContestsBlock from './ContestsBlock'; import { setMenuActivePage } from '../../../redux/slices/store'; import { fetchContests } from '../../../redux/slices/contests'; +import ModalCreateContest from './ModalCreate'; const Contests = () => { const dispatch = useAppDispatch(); const now = new Date(); + const [modalActive, setModalActive] = useState(false); + // Берём данные из Redux const contests = useAppSelector((state) => state.contests.contests); - const loading = useAppSelector((state) => state.contests.status); + const status = useAppSelector((state) => state.contests.statuses.create); const error = useAppSelector((state) => state.contests.error); // При загрузке страницы — выставляем активную вкладку и подгружаем контесты @@ -21,7 +24,7 @@ const Contests = () => { dispatch(fetchContests({})); }, []); - if (loading == 'loading') { + if (status == 'loading') { return (
Загрузка контестов...
); @@ -43,8 +46,10 @@ const Contests = () => { Контесты
{}} - text="Создать группу" + onClick={() => { + setModalActive(true); + }} + text="Создать контест" className="absolute right-0" /> @@ -69,6 +74,11 @@ const Contests = () => { })} /> + + ); }; diff --git a/src/views/home/contests/ContestsBlock.tsx b/src/views/home/contests/ContestsBlock.tsx index d664a86..1740745 100644 --- a/src/views/home/contests/ContestsBlock.tsx +++ b/src/views/home/contests/ContestsBlock.tsx @@ -53,6 +53,7 @@ const ContestsBlock: FC = ({ {contests.map((v, i) => ( void; +} + +const ModalCreateContest: FC = ({ + active, + setActive, +}) => { + const dispatch = useAppDispatch(); + const status = useAppSelector((state) => state.contests.statuses.create); + + const [form, setForm] = useState({ + name: '', + description: '', + scheduleType: 'AlwaysOpen', + visibility: 'Public', + startsAt: null, + endsAt: null, + attemptDurationMinutes: null, + maxAttempts: null, + allowEarlyFinish: false, + groupId: null, + missionIds: null, + articleIds: null, + participantIds: null, + organizerIds: null, + }); + + useEffect(() => { + if (status === 'successful') { + setActive(false); + } + }, [status]); + + const handleChange = (key: keyof CreateContestBody, value: any) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + const handleSubmit = () => { + dispatch(createContest(form)); + }; + + return ( + +
+
+ Создать контест +
+ + handleChange('name', v)} + /> + + handleChange('description', v)} + /> + +
+
+ + +
+ +
+ + +
+
+ + {/* Даты начала и конца */} +
+ +
+ + {/* Продолжительность и лимиты */} +
+ + handleChange('attemptDurationMinutes', Number(v)) + } + /> + handleChange('maxAttempts', Number(v))} + /> +
+ + {/* Разрешить раннее завершение */} +
+ + handleChange('allowEarlyFinish', e.target.checked) + } + /> + +
+ + {/* Кнопки */} +
+ + setActive(false)} + text="Отмена" + /> +
+
+
+ ); +}; + +export default ModalCreateContest; diff --git a/src/views/home/missions/MissionItem.tsx b/src/views/home/missions/MissionItem.tsx index 89b07e4..3d4b6cc 100644 --- a/src/views/home/missions/MissionItem.tsx +++ b/src/views/home/missions/MissionItem.tsx @@ -4,16 +4,16 @@ import { useNavigate } from 'react-router-dom'; export interface MissionItemProps { id: number; - authorId: number; + authorId?: number; name: string; difficulty: 'Easy' | 'Medium' | 'Hard'; - tags: string[]; - timeLimit: number; - memoryLimit: number; - createdAt: string; - updatedAt: string; - type: 'first' | 'second'; - status: 'empty' | 'success' | 'error'; + tags?: string[]; + timeLimit?: number; + memoryLimit?: number; + createdAt?: string; + updatedAt?: string; + type?: 'first' | 'second'; + status?: 'empty' | 'success' | 'error'; } export function formatMilliseconds(ms: number): string { @@ -31,8 +31,8 @@ const MissionItem: React.FC = ({ id, name, difficulty, - timeLimit, - memoryLimit, + timeLimit = 1000, + memoryLimit = 256 * 1024 * 1024, type, status, }) => { @@ -41,7 +41,7 @@ const MissionItem: React.FC = ({ return (
= ({ active, setActive }) => {
- +
{/* Теги */} diff --git a/src/views/mission/submission/Submission.tsx b/src/views/mission/submission/Submission.tsx new file mode 100644 index 0000000..e69de29