my contests

This commit is contained in:
Виталий Лавшонок
2025-11-06 00:41:01 +03:00
parent 4a65aa4b53
commit dc6df1480e
7 changed files with 354 additions and 121 deletions

View File

@@ -70,33 +70,70 @@ export interface CreateContestBody {
type Status = 'idle' | 'loading' | 'successful' | 'failed'; type Status = 'idle' | 'loading' | 'successful' | 'failed';
interface ContestsState { interface ContestsState {
contests: Contest[]; fetchContests: {
selectedContest: Contest | null; contests: Contest[];
hasNextPage: boolean; hasNextPage: boolean;
statuses: { status: Status;
fetchList: Status; error: string | null;
fetchById: Status; };
create: Status; 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 = { const initialState: ContestsState = {
contests: [], fetchContests: {
selectedContest: null, contests: [],
hasNextPage: false, hasNextPage: false,
statuses: { status: 'idle',
fetchList: 'idle', error: null,
fetchById: 'idle', },
create: 'idle', 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 // Async Thunks
// ===================== // =====================
// Все контесты
export const fetchContests = createAsyncThunk( export const fetchContests = createAsyncThunk(
'contests/fetchAll', 'contests/fetchAll',
async ( async (
@@ -121,6 +158,7 @@ export const fetchContests = createAsyncThunk(
}, },
); );
// Контест по ID
export const fetchContestById = createAsyncThunk( export const fetchContestById = createAsyncThunk(
'contests/fetchById', 'contests/fetchById',
async (id: number, { rejectWithValue }) => { async (id: number, { rejectWithValue }) => {
@@ -135,6 +173,7 @@ export const fetchContestById = createAsyncThunk(
}, },
); );
// Создание контеста
export const createContest = createAsyncThunk( export const createContest = createAsyncThunk(
'contests/create', 'contests/create',
async (contestData: CreateContestBody, { rejectWithValue }) => { 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<Contest[]>('/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<ContestsResponse>(
'/contests/registered',
{ params: { page, pageSize } },
);
return response.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Failed to fetch registered contests',
);
}
},
);
// ===================== // =====================
// Slice // Slice
// ===================== // =====================
@@ -161,77 +239,100 @@ const contestsSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
clearSelectedContest: (state) => { clearSelectedContest: (state) => {
state.selectedContest = null; state.fetchContestById.contest = null;
},
setContestStatus: (
state,
action: PayloadAction<{
key: keyof ContestsState['statuses'];
status: Status;
}>,
) => {
state.statuses[action.payload.key] = action.payload.status;
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
// fetchContests // fetchContests
builder.addCase(fetchContests.pending, (state) => { builder.addCase(fetchContests.pending, (state) => {
state.statuses.fetchList = 'loading'; state.fetchContests.status = 'loading';
state.error = null; state.fetchContests.error = null;
}); });
builder.addCase( builder.addCase(
fetchContests.fulfilled, fetchContests.fulfilled,
(state, action: PayloadAction<ContestsResponse>) => { (state, action: PayloadAction<ContestsResponse>) => {
state.statuses.fetchList = 'successful'; state.fetchContests.status = 'successful';
state.contests = action.payload.contests; state.fetchContests.contests = action.payload.contests;
state.hasNextPage = action.payload.hasNextPage; state.fetchContests.hasNextPage = action.payload.hasNextPage;
},
);
builder.addCase(
fetchContests.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchList = 'failed';
state.error = action.payload;
}, },
); );
builder.addCase(fetchContests.rejected, (state, action: any) => {
state.fetchContests.status = 'failed';
state.fetchContests.error = action.payload;
});
// fetchContestById // fetchContestById
builder.addCase(fetchContestById.pending, (state) => { builder.addCase(fetchContestById.pending, (state) => {
state.statuses.fetchById = 'loading'; state.fetchContestById.status = 'loading';
state.error = null; state.fetchContestById.error = null;
}); });
builder.addCase( builder.addCase(
fetchContestById.fulfilled, fetchContestById.fulfilled,
(state, action: PayloadAction<Contest>) => { (state, action: PayloadAction<Contest>) => {
state.statuses.fetchById = 'successful'; state.fetchContestById.status = 'successful';
state.selectedContest = action.payload; state.fetchContestById.contest = action.payload;
},
);
builder.addCase(
fetchContestById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = action.payload;
}, },
); );
builder.addCase(fetchContestById.rejected, (state, action: any) => {
state.fetchContestById.status = 'failed';
state.fetchContestById.error = action.payload;
});
// createContest // createContest
builder.addCase(createContest.pending, (state) => { builder.addCase(createContest.pending, (state) => {
state.statuses.create = 'loading'; state.createContest.status = 'loading';
state.error = null; state.createContest.error = null;
}); });
builder.addCase( builder.addCase(
createContest.fulfilled, createContest.fulfilled,
(state, action: PayloadAction<Contest>) => { (state, action: PayloadAction<Contest>) => {
state.statuses.create = 'successful'; state.createContest.status = 'successful';
state.contests.unshift(action.payload); 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<Contest[]>) => {
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<ContestsResponse>) => {
state.fetchRegisteredContests.status = 'successful';
state.fetchRegisteredContests.contests =
action.payload.contests;
state.fetchRegisteredContests.hasNextPage =
action.payload.hasNextPage;
}, },
); );
builder.addCase( builder.addCase(
createContest.rejected, fetchRegisteredContests.rejected,
(state, action: PayloadAction<any>) => { (state, action: any) => {
state.statuses.create = 'failed'; state.fetchRegisteredContests.status = 'failed';
state.error = action.payload; 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; export const contestsReducer = contestsSlice.reducer;

View File

@@ -1,60 +1,64 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../../redux/slices/store'; import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
import { fetchContests } from '../../../../redux/slices/contests'; import {
fetchMyContests,
fetchRegisteredContests,
} from '../../../../redux/slices/contests';
import ContestsBlock from './ContestsBlock'; import ContestsBlock from './ContestsBlock';
const Contests = () => { const Contests = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const now = new Date();
const [modalActive, setModalActive] = useState<boolean>(false); // Redux-состояния
const myContestsState = useAppSelector(
// Берём данные из Redux (state) => state.contests.fetchMyContests,
const contests = useAppSelector((state) => state.contests.contests); );
const status = useAppSelector((state) => state.contests.statuses.create); const regContestsState = useAppSelector(
const error = useAppSelector((state) => state.contests.error); (state) => state.contests.fetchRegisteredContests,
);
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
useEffect(() => {
dispatch(fetchContests({}));
}, []);
// При загрузке страницы — выставляем вкладку и подгружаем контесты
useEffect(() => { useEffect(() => {
dispatch(setMenuActiveProfilePage('contests')); dispatch(setMenuActiveProfilePage('contests'));
dispatch(fetchMyContests());
dispatch(fetchRegisteredContests({}));
}, []); }, []);
if (status == 'loading') { console.log(myContestsState);
return (
<div className="text-liquid-white p-4">Загрузка контестов...</div>
);
}
if (error) {
return <div className="text-red-500 p-4">Ошибка: {error}</div>;
}
return ( return (
<div className="h-full w-full relative flex flex-col text-[60px] font-bold p-[20px]"> <div className="h-full w-full relative flex flex-col text-[60px] font-bold p-[20px] gap-[20px]">
<ContestsBlock {/* Контесты, в которых я участвую */}
className="mb-[20px]" <div>
type="reg" <ContestsBlock
title="Предстоящие контесты" className="mb-[20px]"
contests={contests.filter((contest) => { title="Предстоящие контесты"
const endTime = new Date(contest.endsAt).getTime(); type="reg"
return endTime >= now.getTime(); // contests={regContestsState.contests}
})} contests={[]}
/> />
</div>
<ContestsBlock {/* Контесты, которые я создал */}
className="mb-[20px]" <div>
title="Мои контесты" {myContestsState.status === 'loading' ? (
type="my" <div className="text-liquid-white p-4 text-[24px]">
contests={contests.filter((contest) => { Загрузка ваших контестов...
const endTime = new Date(contest.endsAt).getTime(); </div>
return endTime < now.getTime(); ) : myContestsState.error ? (
})} <div className="text-red-500 p-4 text-[24px]">
/> Ошибка: {myContestsState.error}
</div>
) : (
<ContestsBlock
className="mb-[20px]"
title="Мои контесты"
type="my"
contests={myContestsState.contests}
/>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -1,20 +1,22 @@
import { useState, FC } from 'react'; import { useState, FC } from 'react';
import { cn } from '../../../../lib/cn'; import { cn } from '../../../../lib/cn';
import { ChevroneDown } from '../../../../assets/icons/groups'; import { ChevroneDown } from '../../../../assets/icons/groups';
import ContestItem from './ContestItem'; import MyContestItem from './MyContestItem';
import RegisterContestItem from './RegisterContestItem';
import { Contest } from '../../../../redux/slices/contests'; import { Contest } from '../../../../redux/slices/contests';
interface ContestsBlockProps { interface ContestsBlockProps {
contests: Contest[]; contests: Contest[];
title: string; title: string;
className?: string; className?: string;
type?: string; type?: 'my' | 'reg';
} }
const ContestsBlock: FC<ContestsBlockProps> = ({ const ContestsBlock: FC<ContestsBlockProps> = ({
contests, contests,
title, title,
className, className,
type = 'my',
}) => { }) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые'); const [active, setActive] = useState<boolean>(title != 'Скрытые');
@@ -51,21 +53,37 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="pb-[10px] pt-[20px]"> <div className="pb-[10px] pt-[20px]">
{contests.map((v, i) => ( {contests.map((v, i) => {
<ContestItem return type == 'my' ? (
key={i} <MyContestItem
id={v.id} key={i}
name={v.name} id={v.id}
startAt={v.startsAt} name={v.name}
statusRegister={'reg'} startAt={v.startsAt}
duration={ statusRegister={'reg'}
new Date(v.endsAt).getTime() - duration={
new Date(v.startsAt).getTime() new Date(v.endsAt).getTime() -
} new Date(v.startsAt).getTime()
members={v.members.length} }
type={i % 2 ? 'second' : 'first'} members={v.members.length}
/> type={i % 2 ? 'second' : 'first'}
))} />
) : (
<RegisterContestItem
key={i}
id={v.id}
name={v.name}
startAt={v.startsAt}
statusRegister={'reg'}
duration={
new Date(v.endsAt).getTime() -
new Date(v.startsAt).getTime()
}
members={v.members.length}
type={i % 2 ? 'second' : 'first'}
/>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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<ContestItemProps> = ({
id,
name,
startAt,
duration,
members,
type,
}) => {
const navigate = useNavigate();
const now = new Date();
const waitTime = new Date(startAt).getTime() - now.getTime();
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid grid-cols-[1fr,1fr,110px,110px,110px,24px] items-center font-bold',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
navigate(`/contest/${id}`);
}}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal ">
{/* {authors.map((v, i) => <p key={i}>{v}</p>)} */}
valavshonok
</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startAt)}
</div>
<div className="text-center">{formatWaitTime(duration)}</div>
<div className="items-center justify-center flex gap-[10px] flex-row w-full">
<div>{members}</div>
<img src={Account} className="h-[24px] w-[24px]" />
</div>
<img
className=" h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
src={Edit}
onClick={(e) => {
e.stopPropagation();
navigate(
`/contest/editor?back=/home/account/articles&articleId=${id}`,
);
}}
/>
</div>
);
};
export default ContestItem;

View File

@@ -14,9 +14,13 @@ const Contests = () => {
const [modalActive, setModalActive] = useState<boolean>(false); const [modalActive, setModalActive] = useState<boolean>(false);
// Берём данные из Redux // Берём данные из Redux
const contests = useAppSelector((state) => state.contests.contests); const contests = useAppSelector(
const status = useAppSelector((state) => state.contests.statuses.create); (state) => state.contests.fetchContests.contests,
const error = useAppSelector((state) => state.contests.error); );
const status = useAppSelector(
(state) => state.contests.fetchContests.status,
);
const error = useAppSelector((state) => state.contests.fetchContests.error);
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты // При загрузке страницы — выставляем активную вкладку и подгружаем контесты
useEffect(() => { useEffect(() => {

View File

@@ -18,7 +18,9 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
setActive, setActive,
}) => { }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const status = useAppSelector((state) => state.contests.statuses.create); const status = useAppSelector(
(state) => state.contests.createContest.status,
);
const [form, setForm] = useState<CreateContestBody>({ const [form, setForm] = useState<CreateContestBody>({
name: '', name: '',