add contests

This commit is contained in:
Виталий Лавшонок
2025-12-05 23:42:18 +03:00
parent 358c7def78
commit fd34761745
36 changed files with 2518 additions and 710 deletions

View File

@@ -1,165 +0,0 @@
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 { toastWarning } from '../../../lib/toastNotification';
import { useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { addOrUpdateContestMember } from '../../../redux/slices/contests';
export type Role = 'None' | 'Participant' | 'Organizer';
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 dispatch = useAppDispatch();
const now = new Date();
const waitTime = new Date(startAt).getTime() - now.getTime();
const [myRole, setMyRole] = useState<Role>('None');
const userId = useAppSelector((state) => state.auth.id);
const { contests: contestsRegistered } = useAppSelector(
(state) => state.contests.fetchParticipating,
);
const { contests: contestsMy } = useAppSelector(
(state) => state.contests.fetchMyContests,
);
useEffect(() => {
if (!contestsRegistered || contestsRegistered.length === 0) {
setMyRole('None');
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]);
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[10px] text-liquid-white text-[16px] leading-[20px] cursor-pointer',
waitTime <= 0 ? 'grid grid-cols-6' : 'grid grid-cols-7',
'items-center font-bold text-liquid-white',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (myRole == 'None') {
toastWarning('Зарегистрируйтесь на контест');
return;
}
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>
{waitTime > 0 && (
<div className="text-center whitespace-pre-line ">
{'До начала\n' + formatWaitTime(waitTime)}
</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>
<div className="flex items-center justify-end">
{myRole == 'None' ? (
<>
{' '}
<PrimaryButton
onClick={() => {
dispatch(
addOrUpdateContestMember({
contestId: id,
member: {
userId: Number(userId),
role: 'Participant',
},
}),
);
}}
text="Регистрация"
/>
</>
) : (
<>
{' '}
<ReverseButton
onClick={() => {
navigate(`/contest/${id}`);
}}
text="Войти"
/>
</>
)}
</div>
</div>
);
};
export default ContestItem;

View File

@@ -4,31 +4,28 @@ import { cn } from '../../../lib/cn';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import ContestsBlock from './ContestsBlock';
import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchContests, fetchMyContests, fetchParticipatingContests } from '../../../redux/slices/contests';
import {
fetchContests,
fetchMyContests,
fetchParticipatingContests,
} from '../../../redux/slices/contests';
import ModalCreateContest from './ModalCreate';
import Filters from './Filter';
const Contests = () => {
const dispatch = useAppDispatch();
const now = new Date();
const [modalActive, setModalActive] = useState<boolean>(false);
// Берём данные из Redux
const contests = useAppSelector(
(state) => state.contests.fetchContests.contests,
const { contests, status } = useAppSelector(
(state) => state.contests.fetchContests,
);
const status = useAppSelector(
(state) => state.contests.fetchContests.status,
);
const error = useAppSelector((state) => state.contests.fetchContests.error);
// При загрузке страницы — выставляем активную вкладку и подгружаем контесты
useEffect(() => {
dispatch(setMenuActivePage('contests'));
dispatch(fetchContests({}));
dispatch(fetchParticipatingContests({pageSize:100}));
dispatch(fetchParticipatingContests({ pageSize: 100 }));
dispatch(fetchMyContests());
}, []);
@@ -58,31 +55,24 @@ const Contests = () => {
Загрузка контестов...
</div>
)}
{status == 'failed' && (
<div className="text-red-500 p-4">Ошибка: {error}</div>
)}
{status == 'successful' && (
<>
<ContestsBlock
className="mb-[20px]"
title="Текущие"
contests={contests.filter((contest) => {
const endTime = new Date(
contest.endsAt ?? new Date().toDateString(),
).getTime();
return endTime >= now.getTime();
})}
contests={contests.filter(
(c) => c.scheduleType != 'AlwaysOpen',
)}
type="upcoming"
/>
<ContestsBlock
className="mb-[20px]"
title=рошедшие"
contests={contests.filter((contest) => {
const endTime = new Date(
contest.endsAt ?? new Date().toDateString(),
).getTime();
return endTime < now.getTime();
})}
title=остоянные"
contests={contests.filter(
(c) => c.scheduleType == 'AlwaysOpen',
)}
type="past"
/>
</>
)}

View File

@@ -1,19 +1,22 @@
import { useState, FC } from 'react';
import { cn } from '../../../lib/cn';
import { ChevroneDown } from '../../../assets/icons/groups';
import ContestItem from './ContestItem';
import { Contest } from '../../../redux/slices/contests';
import PastContestItem from './PastContestItem';
import UpcoingContestItem from './UpcomingContestItem';
interface ContestsBlockProps {
contests: Contest[];
title: string;
className?: string;
type: 'upcoming' | 'past';
}
const ContestsBlock: FC<ContestsBlockProps> = ({
contests,
title,
className,
type,
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
@@ -33,11 +36,11 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
setActive(!active);
}}
>
<span>{title}</span>
<span className=" select-none">{title}</span>
<img
src={ChevroneDown}
className={cn(
'transition-all duration-300',
'transition-all duration-300 select-none',
active && 'rotate-180',
)}
/>
@@ -50,24 +53,51 @@ 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 ?? new Date().toString()}
duration={
new Date(
v.endsAt ?? new Date().toString(),
).getTime() -
new Date(
v.startsAt ?? new Date().toString(),
).getTime()
}
members={v.members?.length ?? 0}
type={i % 2 ? 'second' : 'first'}
/>
))}
{contests.map((v, i) => {
if (type == 'past') {
return (
<PastContestItem
key={i}
contestId={v.id}
scheduleType={v.scheduleType}
name={v.name}
startsAt={
v.startsAt ?? new Date().toString()
}
endsAt={
v.endsAt ?? new Date().toString()
}
attemptDurationMinutes={
v.attemptDurationMinutes ?? 0
}
type={i % 2 ? 'second' : 'first'}
/>
);
}
if (type == 'upcoming') {
return (
<UpcoingContestItem
key={i}
contestId={v.id}
scheduleType={v.scheduleType}
name={v.name}
startsAt={
v.startsAt ?? new Date().toString()
}
endsAt={
v.endsAt ?? new Date().toString()
}
attemptDurationMinutes={
v.attemptDurationMinutes ?? 0
}
type={i % 2 ? 'second' : 'first'}
/>
);
}
return <></>;
})}
</div>
</div>
</div>

View File

@@ -0,0 +1,189 @@
import { cn } from '../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useQuery } from '../../../hooks/useQuery';
import { ReverseButton } from '../../../components/button/ReverseButton';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { toastWarning } from '../../../lib/toastNotification';
import { useEffect, useState } from 'react';
import {
addOrUpdateContestMember,
fetchParticipatingContests,
} from '../../../redux/slices/contests';
export interface PastContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
startsAt: string;
endsAt: string;
attemptDurationMinutes: 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 formatDurationTime(minutes: number): string {
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} мин`;
}
}
type Role = 'None' | 'Participant' | 'Organizer';
const PastContestItem: React.FC<PastContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [role, setRole] = useState<Role>('None');
const myname = useAppSelector((state) => state.auth.username);
const userId = useAppSelector((state) => state.auth.id);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const { contests: myContests } = useAppSelector(
(state) => state.contests.fetchMyContests,
);
const { contests: participantContests } = useAppSelector(
(state) => state.contests.fetchParticipating,
);
useEffect(() => {
setRole(
(() => {
if (myContests?.some((c) => c.id === contestId)) {
return 'Organizer';
}
if (participantContests?.some((c) => c.id === contestId)) {
return 'Participant';
}
return 'None';
})(),
);
}, [myContests, participantContests]);
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
userId
? 'grid-cols-[1fr,150px,190px,120px,150px,150px]'
: 'grid-cols-[1fr,150px,190px,120px,150px]',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (role == 'None') {
toastWarning('Нужно зарегистрироваться на контест');
return;
}
const params = new URLSearchParams({
back: '/home/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
{username}
</div>
{scheduleType == 'AlwaysOpen' ? (
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
Всегда открыт
</div>
) : (
<div className="flex items-center gap-[5px] text-[14px]">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startsAt)}
</div>
<div>-</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(endsAt)}
</div>
</div>
)}
<div className="text-center">
{formatDurationTime(attemptDurationMinutes)}
</div>
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Завершен'}
</div>
{userId && (
<div className="flex items-center justify-center">
{role == 'Organizer' || role == 'Participant' ? (
<ReverseButton
onClick={() => {
const params = new URLSearchParams({
back: '/home/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
text="Войти"
/>
) : (
<PrimaryButton
onClick={() => {
dispatch(
addOrUpdateContestMember({
contestId: contestId,
member: {
userId: Number(userId),
role: 'Participant',
},
}),
)
.unwrap()
.then(() =>
dispatch(
fetchParticipatingContests({}),
),
);
}}
text="Регистрация"
/>
)}
</div>
)}
</div>
);
};
export default PastContestItem;

View File

@@ -0,0 +1,233 @@
import { cn } from '../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useQuery } from '../../../hooks/useQuery';
import { toastWarning } from '../../../lib/toastNotification';
import { useEffect, useState } from 'react';
import { ReverseButton } from '../../../components/button/ReverseButton';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import {
addOrUpdateContestMember,
fetchParticipatingContests,
} from '../../../redux/slices/contests';
type Role = 'None' | 'Participant' | 'Organizer';
export interface UpcoingContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
startsAt: string;
endsAt: string;
attemptDurationMinutes: 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 formatDurationTime(minutes: number): string {
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} мин`;
}
}
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 UpcoingContestItem: React.FC<UpcoingContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [role, setRole] = useState<Role>('None');
const myname = useAppSelector((state) => state.auth.username);
const { contests: myContests } = useAppSelector(
(state) => state.contests.fetchMyContests,
);
const { contests: participantContests } = useAppSelector(
(state) => state.contests.fetchParticipating,
);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const userId = useAppSelector((state) => state.auth.id);
const started = new Date(startsAt) <= new Date();
const finished = new Date(endsAt) <= new Date();
const waitTime = !started
? new Date(startsAt).getTime() - new Date().getTime()
: new Date(endsAt).getTime() - new Date().getTime();
useEffect(() => {
setRole(
(() => {
if (myContests?.some((c) => c.id === contestId)) {
return 'Organizer';
}
if (participantContests?.some((c) => c.id === contestId)) {
return 'Participant';
}
return 'None';
})(),
);
}, [myContests, participantContests]);
return (
<div
className={cn(
'w-full box-border relative rounded-[10px] px-[20px] py-[14px] text-liquid-white text-[16px] leading-[20px] cursor-pointer grid items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
userId
? 'grid-cols-[1fr,1fr,220px,130px,130px,140px,150px]'
: 'grid-cols-[1fr,1fr,220px,130px,130px,130px]',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (!started) {
toastWarning('Контест еще не начался');
return;
}
const params = new URLSearchParams({
back: '/home/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
>
<div className="text-left font-bold text-[18px]">{name}</div>
<div className="text-center text-liquid-brightmain font-normal flex items-center justify-center">
{username}
</div>
{scheduleType == 'AlwaysOpen' ? (
<div className="text-center text-nowrap whitespace-pre-line text-[14px]">
Всегда открыт
</div>
) : (
<div className="flex items-center gap-[5px] text-[14px]">
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(startsAt)}
</div>
<div>-</div>
<div className="text-center text-nowrap whitespace-pre-line">
{formatDate(endsAt)}
</div>
</div>
)}
<div className="text-center">
{formatDurationTime(attemptDurationMinutes)}
</div>
{!started ? (
<div className="text-center whitespace-pre-line ">
{'До начала\n' + formatWaitTime(waitTime)}
</div>
) : (
!finished && (
<div className="text-center whitespace-pre-line ">
{'До конца\n' + formatWaitTime(waitTime)}
</div>
)
)}
<div className="flex items-center justify-center text-liquid-brightmain font-normal">
{new Date() < new Date(startsAt) ? (
<>{'Не начался'}</>
) : (
<>{scheduleType == 'AlwaysOpen' ? 'Открыт' : 'Идет'}</>
)}
</div>
{userId && (
<div className="flex items-center justify-center">
{role == 'Organizer' || role == 'Participant' ? (
<ReverseButton
onClick={() => {
const params = new URLSearchParams({
back: '/home/contests',
});
navigate(`/contest/${contestId}?${params}`);
}}
text="Войти"
/>
) : (
<PrimaryButton
onClick={() => {
dispatch(
addOrUpdateContestMember({
contestId: contestId,
member: {
userId: Number(userId),
role: 'Participant',
},
}),
)
.unwrap()
.then(() =>
dispatch(
fetchParticipatingContests({}),
),
);
}}
text="Регистрация"
/>
)}
</div>
)}
</div>
);
};
export default UpcoingContestItem;