From dc6df1480e1ac8ace5aba173ed1e365e6d608ad2 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: Thu, 6 Nov 2025 00:41:01 +0300 Subject: [PATCH] my contests --- src/redux/slices/contests.ts | 217 +++++++++++++----- src/views/home/account/contests/Contests.tsx | 88 +++---- .../home/account/contests/ContestsBlock.tsx | 52 +++-- .../home/account/contests/MyContestItem.tsx | 104 +++++++++ ...ontestItem.tsx => RegisterContestItem.tsx} | 0 src/views/home/contests/Contests.tsx | 10 +- src/views/home/contests/ModalCreate.tsx | 4 +- 7 files changed, 354 insertions(+), 121 deletions(-) create mode 100644 src/views/home/account/contests/MyContestItem.tsx rename src/views/home/account/contests/{ContestItem.tsx => RegisterContestItem.tsx} (100%) diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index a5302ec..1bbcae5 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -70,33 +70,70 @@ export interface CreateContestBody { type Status = 'idle' | 'loading' | 'successful' | 'failed'; interface ContestsState { - contests: Contest[]; - selectedContest: Contest | null; - hasNextPage: boolean; - statuses: { - fetchList: Status; - fetchById: Status; - create: Status; + fetchContests: { + contests: Contest[]; + hasNextPage: boolean; + status: Status; + error: string | null; + }; + fetchContestById: { + contest: Contest | null; + status: Status; + error: string | null; + }; + createContest: { + contest: Contest | null; + status: Status; + error: string | null; + }; + fetchMyContests: { + contests: Contest[]; + status: Status; + error: string | null; + }; + fetchRegisteredContests: { + contests: Contest[]; + hasNextPage: boolean; + status: Status; + error: string | null; }; - error: string | null; } const initialState: ContestsState = { - contests: [], - selectedContest: null, - hasNextPage: false, - statuses: { - fetchList: 'idle', - fetchById: 'idle', - create: 'idle', + fetchContests: { + contests: [], + hasNextPage: false, + status: 'idle', + error: null, + }, + fetchContestById: { + contest: null, + status: 'idle', + error: null, + }, + createContest: { + contest: null, + status: 'idle', + error: null, + }, + fetchMyContests: { + contests: [], + status: 'idle', + error: null, + }, + fetchRegisteredContests: { + contests: [], + hasNextPage: false, + status: 'idle', + error: null, }, - error: null, }; // ===================== // Async Thunks // ===================== +// Все контесты export const fetchContests = createAsyncThunk( 'contests/fetchAll', async ( @@ -121,6 +158,7 @@ export const fetchContests = createAsyncThunk( }, ); +// Контест по ID export const fetchContestById = createAsyncThunk( 'contests/fetchById', async (id: number, { rejectWithValue }) => { @@ -135,6 +173,7 @@ export const fetchContestById = createAsyncThunk( }, ); +// Создание контеста export const createContest = createAsyncThunk( 'contests/create', async (contestData: CreateContestBody, { rejectWithValue }) => { @@ -152,6 +191,45 @@ export const createContest = createAsyncThunk( }, ); +// Контесты, созданные мной +export const fetchMyContests = createAsyncThunk( + 'contests/fetchMyContests', + async (_, { rejectWithValue }) => { + try { + const response = await axios.get('/contests/my'); + // Возвращаем просто массив контестов + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to fetch my contests', + ); + } + }, +); + +// Контесты, где я зарегистрирован +export const fetchRegisteredContests = createAsyncThunk( + 'contests/fetchRegisteredContests', + async ( + params: { page?: number; pageSize?: number } = {}, + { rejectWithValue }, + ) => { + try { + const { page = 0, pageSize = 10 } = params; + const response = await axios.get( + '/contests/registered', + { params: { page, pageSize } }, + ); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Failed to fetch registered contests', + ); + } + }, +); + // ===================== // Slice // ===================== @@ -161,77 +239,100 @@ const contestsSlice = createSlice({ initialState, reducers: { clearSelectedContest: (state) => { - state.selectedContest = null; - }, - setContestStatus: ( - state, - action: PayloadAction<{ - key: keyof ContestsState['statuses']; - status: Status; - }>, - ) => { - state.statuses[action.payload.key] = action.payload.status; + state.fetchContestById.contest = null; }, }, extraReducers: (builder) => { // fetchContests builder.addCase(fetchContests.pending, (state) => { - state.statuses.fetchList = 'loading'; - state.error = null; + state.fetchContests.status = 'loading'; + state.fetchContests.error = null; }); builder.addCase( fetchContests.fulfilled, (state, action: PayloadAction) => { - state.statuses.fetchList = 'successful'; - state.contests = action.payload.contests; - state.hasNextPage = action.payload.hasNextPage; - }, - ); - builder.addCase( - fetchContests.rejected, - (state, action: PayloadAction) => { - state.statuses.fetchList = 'failed'; - state.error = action.payload; + state.fetchContests.status = 'successful'; + state.fetchContests.contests = action.payload.contests; + state.fetchContests.hasNextPage = action.payload.hasNextPage; }, ); + builder.addCase(fetchContests.rejected, (state, action: any) => { + state.fetchContests.status = 'failed'; + state.fetchContests.error = action.payload; + }); // fetchContestById builder.addCase(fetchContestById.pending, (state) => { - state.statuses.fetchById = 'loading'; - state.error = null; + state.fetchContestById.status = 'loading'; + state.fetchContestById.error = null; }); builder.addCase( fetchContestById.fulfilled, (state, action: PayloadAction) => { - state.statuses.fetchById = 'successful'; - state.selectedContest = action.payload; - }, - ); - builder.addCase( - fetchContestById.rejected, - (state, action: PayloadAction) => { - state.statuses.fetchById = 'failed'; - state.error = action.payload; + state.fetchContestById.status = 'successful'; + state.fetchContestById.contest = action.payload; }, ); + builder.addCase(fetchContestById.rejected, (state, action: any) => { + state.fetchContestById.status = 'failed'; + state.fetchContestById.error = action.payload; + }); // createContest builder.addCase(createContest.pending, (state) => { - state.statuses.create = 'loading'; - state.error = null; + state.createContest.status = 'loading'; + state.createContest.error = null; }); builder.addCase( createContest.fulfilled, (state, action: PayloadAction) => { - state.statuses.create = 'successful'; - state.contests.unshift(action.payload); + state.createContest.status = 'successful'; + state.createContest.contest = action.payload; + }, + ); + builder.addCase(createContest.rejected, (state, action: any) => { + state.createContest.status = 'failed'; + state.createContest.error = action.payload; + }); + + // fetchMyContests + // fetchMyContests + builder.addCase(fetchMyContests.pending, (state) => { + state.fetchMyContests.status = 'loading'; + state.fetchMyContests.error = null; + }); + builder.addCase( + fetchMyContests.fulfilled, + (state, action: PayloadAction) => { + state.fetchMyContests.status = 'successful'; + state.fetchMyContests.contests = action.payload; + }, + ); + builder.addCase(fetchMyContests.rejected, (state, action: any) => { + state.fetchMyContests.status = 'failed'; + state.fetchMyContests.error = action.payload; + }); + + // fetchRegisteredContests + builder.addCase(fetchRegisteredContests.pending, (state) => { + state.fetchRegisteredContests.status = 'loading'; + state.fetchRegisteredContests.error = null; + }); + builder.addCase( + fetchRegisteredContests.fulfilled, + (state, action: PayloadAction) => { + state.fetchRegisteredContests.status = 'successful'; + state.fetchRegisteredContests.contests = + action.payload.contests; + state.fetchRegisteredContests.hasNextPage = + action.payload.hasNextPage; }, ); builder.addCase( - createContest.rejected, - (state, action: PayloadAction) => { - state.statuses.create = 'failed'; - state.error = action.payload; + fetchRegisteredContests.rejected, + (state, action: any) => { + state.fetchRegisteredContests.status = 'failed'; + state.fetchRegisteredContests.error = action.payload; }, ); }, @@ -241,5 +342,5 @@ const contestsSlice = createSlice({ // Экспорты // ===================== -export const { clearSelectedContest, setContestStatus } = contestsSlice.actions; +export const { clearSelectedContest } = contestsSlice.actions; export const contestsReducer = contestsSlice.reducer; diff --git a/src/views/home/account/contests/Contests.tsx b/src/views/home/account/contests/Contests.tsx index a68f224..8f96a04 100644 --- a/src/views/home/account/contests/Contests.tsx +++ b/src/views/home/account/contests/Contests.tsx @@ -1,60 +1,64 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; -import { fetchContests } from '../../../../redux/slices/contests'; +import { + fetchMyContests, + fetchRegisteredContests, +} from '../../../../redux/slices/contests'; import ContestsBlock from './ContestsBlock'; const Contests = () => { const dispatch = useAppDispatch(); - const now = new Date(); - const [modalActive, setModalActive] = useState(false); - - // Берём данные из Redux - const contests = useAppSelector((state) => state.contests.contests); - const status = useAppSelector((state) => state.contests.statuses.create); - const error = useAppSelector((state) => state.contests.error); - - // При загрузке страницы — выставляем активную вкладку и подгружаем контесты - useEffect(() => { - dispatch(fetchContests({})); - }, []); + // Redux-состояния + const myContestsState = useAppSelector( + (state) => state.contests.fetchMyContests, + ); + const regContestsState = useAppSelector( + (state) => state.contests.fetchRegisteredContests, + ); + // При загрузке страницы — выставляем вкладку и подгружаем контесты useEffect(() => { dispatch(setMenuActiveProfilePage('contests')); + dispatch(fetchMyContests()); + dispatch(fetchRegisteredContests({})); }, []); - if (status == 'loading') { - return ( -
Загрузка контестов...
- ); - } - - if (error) { - return
Ошибка: {error}
; - } + console.log(myContestsState); return ( -
- { - const endTime = new Date(contest.endsAt).getTime(); - return endTime >= now.getTime(); - })} - /> +
+ {/* Контесты, в которых я участвую */} +
+ +
- { - const endTime = new Date(contest.endsAt).getTime(); - return endTime < now.getTime(); - })} - /> + {/* Контесты, которые я создал */} +
+ {myContestsState.status === 'loading' ? ( +
+ Загрузка ваших контестов... +
+ ) : myContestsState.error ? ( +
+ Ошибка: {myContestsState.error} +
+ ) : ( + + )} +
); }; diff --git a/src/views/home/account/contests/ContestsBlock.tsx b/src/views/home/account/contests/ContestsBlock.tsx index fbe2ded..f946fd0 100644 --- a/src/views/home/account/contests/ContestsBlock.tsx +++ b/src/views/home/account/contests/ContestsBlock.tsx @@ -1,20 +1,22 @@ import { useState, FC } from 'react'; import { cn } from '../../../../lib/cn'; import { ChevroneDown } from '../../../../assets/icons/groups'; -import ContestItem from './ContestItem'; +import MyContestItem from './MyContestItem'; +import RegisterContestItem from './RegisterContestItem'; import { Contest } from '../../../../redux/slices/contests'; interface ContestsBlockProps { contests: Contest[]; title: string; className?: string; - type?: string; + type?: 'my' | 'reg'; } const ContestsBlock: FC = ({ contests, title, className, + type = 'my', }) => { const [active, setActive] = useState(title != 'Скрытые'); @@ -51,21 +53,37 @@ const ContestsBlock: FC = ({ >
- {contests.map((v, i) => ( - - ))} + {contests.map((v, i) => { + return type == 'my' ? ( + + ) : ( + + ); + })}
diff --git a/src/views/home/account/contests/MyContestItem.tsx b/src/views/home/account/contests/MyContestItem.tsx new file mode 100644 index 0000000..3d2e56e --- /dev/null +++ b/src/views/home/account/contests/MyContestItem.tsx @@ -0,0 +1,104 @@ +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'; +import { Edit } from '../../../../assets/icons/input'; + +export interface ContestItemProps { + id: number; + name: string; + startAt: string; + duration: number; + members: number; + type: 'first' | 'second'; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + const day = date.getDate().toString().padStart(2, '0'); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const year = date.getFullYear(); + + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return `${day}/${month}/${year}\n${hours}:${minutes}`; +} + +function formatWaitTime(ms: number): string { + const minutes = Math.floor(ms / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainder = days % 10; + let suffix = 'дней'; + if (remainder === 1 && days !== 11) suffix = 'день'; + else if (remainder >= 2 && remainder <= 4 && (days < 10 || days > 20)) + suffix = 'дня'; + return `${days} ${suffix}`; + } else if (hours > 0) { + const mins = minutes % 60; + return mins > 0 ? `${hours} ч ${mins} мин` : `${hours} ч`; + } else { + return `${minutes} мин`; + } +} + +const ContestItem: React.FC = ({ + id, + name, + startAt, + duration, + members, + type, +}) => { + const navigate = useNavigate(); + + const now = new Date(); + + const waitTime = new Date(startAt).getTime() - now.getTime(); + + return ( +
{ + navigate(`/contest/${id}`); + }} + > +
{name}
+
+ {/* {authors.map((v, i) =>

{v}

)} */} + valavshonok +
+
+ {formatDate(startAt)} +
+
{formatWaitTime(duration)}
+
+
{members}
+ +
+ + { + e.stopPropagation(); + navigate( + `/contest/editor?back=/home/account/articles&articleId=${id}`, + ); + }} + /> +
+ ); +}; + +export default ContestItem; diff --git a/src/views/home/account/contests/ContestItem.tsx b/src/views/home/account/contests/RegisterContestItem.tsx similarity index 100% rename from src/views/home/account/contests/ContestItem.tsx rename to src/views/home/account/contests/RegisterContestItem.tsx diff --git a/src/views/home/contests/Contests.tsx b/src/views/home/contests/Contests.tsx index d3ad1eb..7af6f32 100644 --- a/src/views/home/contests/Contests.tsx +++ b/src/views/home/contests/Contests.tsx @@ -14,9 +14,13 @@ const Contests = () => { const [modalActive, setModalActive] = useState(false); // Берём данные из Redux - const contests = useAppSelector((state) => state.contests.contests); - const status = useAppSelector((state) => state.contests.statuses.create); - const error = useAppSelector((state) => state.contests.error); + const contests = useAppSelector( + (state) => state.contests.fetchContests.contests, + ); + const status = useAppSelector( + (state) => state.contests.fetchContests.status, + ); + const error = useAppSelector((state) => state.contests.fetchContests.error); // При загрузке страницы — выставляем активную вкладку и подгружаем контесты useEffect(() => { diff --git a/src/views/home/contests/ModalCreate.tsx b/src/views/home/contests/ModalCreate.tsx index ac9cc1c..54abe1a 100644 --- a/src/views/home/contests/ModalCreate.tsx +++ b/src/views/home/contests/ModalCreate.tsx @@ -18,7 +18,9 @@ const ModalCreateContest: FC = ({ setActive, }) => { const dispatch = useAppDispatch(); - const status = useAppSelector((state) => state.contests.statuses.create); + const status = useAppSelector( + (state) => state.contests.createContest.status, + ); const [form, setForm] = useState({ name: '',