my contests
This commit is contained in:
@@ -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<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
|
||||
// =====================
|
||||
@@ -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<ContestsResponse>) => {
|
||||
state.statuses.fetchList = 'successful';
|
||||
state.contests = action.payload.contests;
|
||||
state.hasNextPage = action.payload.hasNextPage;
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
fetchContests.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
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<Contest>) => {
|
||||
state.statuses.fetchById = 'successful';
|
||||
state.selectedContest = action.payload;
|
||||
},
|
||||
);
|
||||
builder.addCase(
|
||||
fetchContestById.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
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<Contest>) => {
|
||||
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<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(
|
||||
createContest.rejected,
|
||||
(state, action: PayloadAction<any>) => {
|
||||
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;
|
||||
|
||||
@@ -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<boolean>(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 (
|
||||
<div className="text-liquid-white p-4">Загрузка контестов...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500 p-4">Ошибка: {error}</div>;
|
||||
}
|
||||
console.log(myContestsState);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative flex flex-col text-[60px] font-bold p-[20px]">
|
||||
<ContestsBlock
|
||||
className="mb-[20px]"
|
||||
type="reg"
|
||||
title="Предстоящие контесты"
|
||||
contests={contests.filter((contest) => {
|
||||
const endTime = new Date(contest.endsAt).getTime();
|
||||
return endTime >= now.getTime();
|
||||
})}
|
||||
/>
|
||||
<div className="h-full w-full relative flex flex-col text-[60px] font-bold p-[20px] gap-[20px]">
|
||||
{/* Контесты, в которых я участвую */}
|
||||
<div>
|
||||
<ContestsBlock
|
||||
className="mb-[20px]"
|
||||
title="Предстоящие контесты"
|
||||
type="reg"
|
||||
// contests={regContestsState.contests}
|
||||
contests={[]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ContestsBlock
|
||||
className="mb-[20px]"
|
||||
title="Мои контесты"
|
||||
type="my"
|
||||
contests={contests.filter((contest) => {
|
||||
const endTime = new Date(contest.endsAt).getTime();
|
||||
return endTime < now.getTime();
|
||||
})}
|
||||
/>
|
||||
{/* Контесты, которые я создал */}
|
||||
<div>
|
||||
{myContestsState.status === 'loading' ? (
|
||||
<div className="text-liquid-white p-4 text-[24px]">
|
||||
Загрузка ваших контестов...
|
||||
</div>
|
||||
) : 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<ContestsBlockProps> = ({
|
||||
contests,
|
||||
title,
|
||||
className,
|
||||
type = 'my',
|
||||
}) => {
|
||||
const [active, setActive] = useState<boolean>(title != 'Скрытые');
|
||||
|
||||
@@ -51,21 +53,37 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="pb-[10px] pt-[20px]">
|
||||
{contests.map((v, i) => (
|
||||
<ContestItem
|
||||
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'}
|
||||
/>
|
||||
))}
|
||||
{contests.map((v, i) => {
|
||||
return type == 'my' ? (
|
||||
<MyContestItem
|
||||
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'}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
104
src/views/home/account/contests/MyContestItem.tsx
Normal file
104
src/views/home/account/contests/MyContestItem.tsx
Normal 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;
|
||||
@@ -14,9 +14,13 @@ const Contests = () => {
|
||||
const [modalActive, setModalActive] = useState<boolean>(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(() => {
|
||||
|
||||
@@ -18,7 +18,9 @@ const ModalCreateContest: FC<ModalCreateContestProps> = ({
|
||||
setActive,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useAppSelector((state) => state.contests.statuses.create);
|
||||
const status = useAppSelector(
|
||||
(state) => state.contests.createContest.status,
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<CreateContestBody>({
|
||||
name: '',
|
||||
|
||||
Reference in New Issue
Block a user