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

@@ -273,7 +273,7 @@ export const fetchParticipatingContests = createAsyncThunk(
{ rejectWithValue },
) => {
try {
const { page = 0, pageSize = 10 } = params;
const { page = 0, pageSize = 100 } = params;
const response = await axios.get<ContestsResponse>(
'/contests/participating',
{ params: { page, pageSize } },
@@ -315,7 +315,7 @@ export const fetchContests = createAsyncThunk(
{ rejectWithValue },
) => {
try {
const { page = 0, pageSize = 10, groupId } = params;
const { page = 0, pageSize = 100, groupId } = params;
const response = await axios.get<ContestsResponse>('/contests', {
params: { page, pageSize, groupId },
});
@@ -417,7 +417,7 @@ export const fetchRegisteredContests = createAsyncThunk(
{ rejectWithValue },
) => {
try {
const { page = 0, pageSize = 10 } = params;
const { page = 0, pageSize = 100 } = params;
const response = await axios.get<ContestsResponse>(
'/contests/registered',
{ params: { page, pageSize } },

View File

@@ -94,7 +94,7 @@ export const fetchGroupPosts = createAsyncThunk(
{
groupId,
page = 0,
pageSize = 20,
pageSize = 100,
}: { groupId: number; page?: number; pageSize?: number },
{ rejectWithValue },
) => {

395
src/redux/slices/profile.ts Normal file
View File

@@ -0,0 +1,395 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// =====================
// Типы
// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed';
// Основной профиль
export interface ProfileIdentity {
userId: number;
username: string;
email: string;
createdAt: string;
}
export interface ProfileSolutions {
totalSolved: number;
solvedLast7Days: number;
}
export interface ProfileContestsInfo {
totalParticipations: number;
participationsLast7Days: number;
}
export interface ProfileCreationStats {
missions: { total: number; last7Days: number };
contests: { total: number; last7Days: number };
articles: { total: number; last7Days: number };
}
export interface ProfileResponse {
identity: ProfileIdentity;
solutions: ProfileSolutions;
contests: ProfileContestsInfo;
creation: ProfileCreationStats;
}
// Missions
export interface MissionsBucket {
key: string;
label: string;
solved: number;
total: number;
}
export interface MissionItem {
missionId: number;
missionName: string;
difficultyLabel: string;
difficultyValue: number;
createdAt: string;
timeLimitMilliseconds: number;
memoryLimitBytes: number;
}
export interface MissionsResponse {
summary: {
total: MissionsBucket;
buckets: MissionsBucket[];
};
recent: {
items: MissionItem[];
page: number;
pageSize: number;
hasNextPage: boolean;
};
authored: {
items: MissionItem[];
page: number;
pageSize: number;
hasNextPage: boolean;
};
}
// Articles
export interface ProfileArticleItem {
articleId: number;
title: string;
createdAt: string;
updatedAt: string;
}
export interface ProfileArticlesResponse {
articles: {
items: ProfileArticleItem[];
page: number;
pageSize: number;
hasNextPage: boolean;
};
}
// Contests
export interface ContestItem {
contestId: number;
name: string;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
role: 'None' | 'Participant' | 'Organizer';
}
export interface ContestsList {
items: ContestItem[];
page: number;
pageSize: number;
hasNextPage: boolean;
}
export interface ProfileContestsResponse {
upcoming: ContestsList;
past: ContestsList;
mine: ContestsList;
}
// =====================
// Состояние
// =====================
interface ProfileState {
profile: {
data?: ProfileResponse;
status: Status;
error?: string;
};
missions: {
data?: MissionsResponse;
status: Status;
error?: string;
};
articles: {
data?: ProfileArticlesResponse;
status: Status;
error?: string;
};
contests: {
data?: ProfileContestsResponse;
status: Status;
error?: string;
};
}
const initialState: ProfileState = {
profile: {
data: undefined,
status: 'idle',
error: undefined,
},
missions: {
data: undefined,
status: 'idle',
error: undefined,
},
articles: {
data: undefined,
status: 'idle',
error: undefined,
},
contests: {
data: undefined,
status: 'idle',
error: undefined,
},
};
// =====================
// Async Thunks
// =====================
// Основной профиль
export const fetchProfile = createAsyncThunk(
'profile/fetch',
async (username: string, { rejectWithValue }) => {
try {
const res = await axios.get<ProfileResponse>(
`/profile/${username}`,
);
return res.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки профиля',
);
}
},
);
// Missions
export const fetchProfileMissions = createAsyncThunk(
'profile/fetchMissions',
async (
{
username,
recentPage = 0,
recentPageSize = 15,
authoredPage = 0,
authoredPageSize = 25,
}: {
username: string;
recentPage?: number;
recentPageSize?: number;
authoredPage?: number;
authoredPageSize?: number;
},
{ rejectWithValue },
) => {
try {
const res = await axios.get<MissionsResponse>(
`/profile/${username}/missions`,
{
params: {
recentPage,
recentPageSize,
authoredPage,
authoredPageSize,
},
},
);
return res.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки задач',
);
}
},
);
// Articles
export const fetchProfileArticles = createAsyncThunk(
'profile/fetchArticles',
async (
{
username,
page = 0,
pageSize = 25,
}: { username: string; page?: number; pageSize?: number },
{ rejectWithValue },
) => {
try {
const res = await axios.get<ProfileArticlesResponse>(
`/profile/${username}/articles`,
{ params: { page, pageSize } },
);
return res.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки статей',
);
}
},
);
// Contests
export const fetchProfileContests = createAsyncThunk(
'profile/fetchContests',
async (
{
username,
upcomingPage = 0,
upcomingPageSize = 10,
pastPage = 0,
pastPageSize = 10,
minePage = 0,
minePageSize = 10,
}: {
username: string;
upcomingPage?: number;
upcomingPageSize?: number;
pastPage?: number;
pastPageSize?: number;
minePage?: number;
minePageSize?: number;
},
{ rejectWithValue },
) => {
try {
const res = await axios.get<ProfileContestsResponse>(
`/profile/${username}/contests`,
{
params: {
upcomingPage,
upcomingPageSize,
pastPage,
pastPageSize,
minePage,
minePageSize,
},
},
);
return res.data;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка загрузки контестов',
);
}
},
);
// =====================
// Slice
// =====================
const profileSlice = createSlice({
name: 'profile',
initialState,
reducers: {
setProfileStatus: (
state,
action: PayloadAction<{
key: keyof ProfileState;
status: Status;
}>,
) => {
state[action.payload.key].status = action.payload.status;
},
},
extraReducers: (builder) => {
// PROFILE
builder.addCase(fetchProfile.pending, (state) => {
state.profile.status = 'loading';
state.profile.error = undefined;
});
builder.addCase(
fetchProfile.fulfilled,
(state, action: PayloadAction<ProfileResponse>) => {
state.profile.status = 'successful';
state.profile.data = action.payload;
},
);
builder.addCase(fetchProfile.rejected, (state, action: any) => {
state.profile.status = 'failed';
state.profile.error = action.payload;
});
// MISSIONS
builder.addCase(fetchProfileMissions.pending, (state) => {
state.missions.status = 'loading';
state.missions.error = undefined;
});
builder.addCase(
fetchProfileMissions.fulfilled,
(state, action: PayloadAction<MissionsResponse>) => {
state.missions.status = 'successful';
state.missions.data = action.payload;
},
);
builder.addCase(fetchProfileMissions.rejected, (state, action: any) => {
state.missions.status = 'failed';
state.missions.error = action.payload;
});
// ARTICLES
builder.addCase(fetchProfileArticles.pending, (state) => {
state.articles.status = 'loading';
state.articles.error = undefined;
});
builder.addCase(
fetchProfileArticles.fulfilled,
(state, action: PayloadAction<ProfileArticlesResponse>) => {
state.articles.status = 'successful';
state.articles.data = action.payload;
},
);
builder.addCase(fetchProfileArticles.rejected, (state, action: any) => {
state.articles.status = 'failed';
state.articles.error = action.payload;
});
// CONTESTS
builder.addCase(fetchProfileContests.pending, (state) => {
state.contests.status = 'loading';
state.contests.error = undefined;
});
builder.addCase(
fetchProfileContests.fulfilled,
(state, action: PayloadAction<ProfileContestsResponse>) => {
state.contests.status = 'successful';
state.contests.data = action.payload;
},
);
builder.addCase(fetchProfileContests.rejected, (state, action: any) => {
state.contests.status = 'failed';
state.contests.error = action.payload;
});
},
});
export const { setProfileStatus } = profileSlice.actions;
export const profileReducer = profileSlice.reducer;

View File

@@ -7,6 +7,9 @@ interface StorState {
activeProfilePage: string;
activeGroupPage: string;
};
group: {
groupFilter: string;
};
}
// Инициализация состояния
@@ -16,6 +19,9 @@ const initialState: StorState = {
activeProfilePage: '',
activeGroupPage: '',
},
group: {
groupFilter: '',
},
};
// Slice
@@ -38,6 +44,9 @@ const storeSlice = createSlice({
) => {
state.menu.activeGroupPage = activeGroupPage.payload;
},
setGroupFilter: (state, groupFilter: PayloadAction<string>) => {
state.group.groupFilter = groupFilter.payload;
},
},
});
@@ -45,6 +54,7 @@ export const {
setMenuActivePage,
setMenuActiveProfilePage,
setMenuActiveGroupPage,
setGroupFilter,
} = storeSlice.actions;
export const storeReducer = storeSlice.reducer;

View File

@@ -8,6 +8,7 @@ import { groupsReducer } from './slices/groups';
import { articlesReducer } from './slices/articles';
import { groupFeedReducer } from './slices/groupfeed';
import { groupChatReducer } from './slices/groupChat';
import { profileReducer } from './slices/profile';
// использование
// import { useAppDispatch, useAppSelector } from '../redux/hooks';
@@ -29,6 +30,7 @@ export const store = configureStore({
articles: articlesReducer,
groupfeed: groupFeedReducer,
groupchat: groupChatReducer,
profile: profileReducer,
},
});

View File

@@ -4,19 +4,47 @@ import RightPanel from './RightPanel';
import Missions from './missions/Missions';
import Contests from './contests/Contests';
import ArticlesBlock from './articles/ArticlesBlock';
import { useAppDispatch } from '../../../redux/hooks';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { useEffect } from 'react';
import { setMenuActivePage } from '../../../redux/slices/store';
import { useQuery } from '../../../hooks/useQuery';
import {
fetchProfile,
fetchProfileArticles,
fetchProfileContests,
fetchProfileMissions,
} from '../../../redux/slices/profile';
const Account = () => {
const dispatch = useAppDispatch();
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
useEffect(() => {
dispatch(setMenuActivePage('account'));
}, []);
if (username == myname) dispatch(setMenuActivePage('account'));
dispatch(
fetchProfileMissions({
username: username,
recentPageSize: 1,
authoredPageSize: 100,
}),
);
dispatch(fetchProfileArticles({ username: username, pageSize: 100 }));
dispatch(
fetchProfileContests({
username: username,
pastPageSize: 100,
minePageSize: 100,
upcomingPageSize: 100,
}),
);
dispatch(fetchProfile(username));
}, [username]);
return (
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,520px] relative divide-x-[1px] divide-liquid-lighter">
<div className="h-full w-[calc(100%+250px)] box-border grid grid-cols-[1fr,430px] relative divide-x-[1px] divide-liquid-lighter">
<div className=" h-full min-h-0 flex flex-col">
<div className=" h-full grid grid-rows-[80px,1fr] ">
<div className="h-full w-full">

View File

@@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { logout } from '../../../redux/slices/auth';
import { OpenBook, Clipboard, Cup } from '../../../assets/icons/account';
import { FC } from 'react';
import { useQuery } from '../../../hooks/useQuery';
interface StatisticItemProps {
icon: string;
@@ -34,32 +35,55 @@ const StatisticItem: FC<StatisticItemProps> = ({
);
};
export const formatDate = (isoDate?: string): string => {
if (!isoDate) return '';
const date = new Date(isoDate);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`;
};
const RightPanel = () => {
const dispatch = useAppDispatch();
const name = useAppSelector((state) => state.auth.username);
const email = useAppSelector((state) => state.auth.email);
const { data: profileData } = useAppSelector(
(state) => state.profile.profile,
);
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
return (
<div className="h-full w-full relative flex flex-col p-[20px] pt-[35px] gap-[20px]">
<div className="grid grid-cols-[150px,1fr] h-[150px] gap-[20px]">
<div className="-hfull w-full bg-[#B8B8B8] rounded-[10px]"></div>
<div className=" relative">
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
{name}
{profileData?.identity.username}
</div>
<div className="text-liquid-light text-[18px] leading-[23px] font-medium">
{email}
</div>
<div className=" absolute bottom-0 text-liquid-light text-[24px] leading-[30px] font-bold">
Топ 50%
{profileData?.identity.email}
</div>
</div>
</div>
<PrimaryButton
onClick={() => {}}
text="Редактировать"
className="w-full"
/>
<div className=" text-liquid-light text-[18px] leading-[30px] font-bold">
{`Зарегистрирован ${formatDate(
profileData?.identity.createdAt,
)}`}
</div>
{username == myname && (
<PrimaryButton
onClick={() => {}}
text="Редактировать"
className="w-full"
/>
)}
<div className="h-[1px] w-full bg-liquid-lighter"></div>
@@ -70,14 +94,14 @@ const RightPanel = () => {
<StatisticItem
icon={Clipboard}
title={'Задачи'}
count={14}
countLastWeek={5}
count={profileData?.solutions.totalSolved}
countLastWeek={profileData?.solutions.solvedLast7Days}
/>
<StatisticItem
icon={Cup}
title={'Контесты'}
count={8}
countLastWeek={2}
count={profileData?.contests.totalParticipations}
countLastWeek={profileData?.contests.participationsLast7Days}
/>
<div className="text-liquid-white text-[24px] leading-[30px] font-bold">
@@ -87,30 +111,32 @@ const RightPanel = () => {
<StatisticItem
icon={Clipboard}
title={'Задачи'}
count={4}
countLastWeek={2}
count={profileData?.creation.missions.total}
countLastWeek={profileData?.creation.missions.last7Days}
/>
<StatisticItem
icon={OpenBook}
title={'Статьи'}
count={12}
countLastWeek={4}
count={profileData?.creation.articles.total}
countLastWeek={profileData?.creation.articles.last7Days}
/>
<StatisticItem
icon={Cup}
title={'Контесты'}
count={2}
countLastWeek={0}
count={profileData?.creation.contests.total}
countLastWeek={profileData?.creation.contests.last7Days}
/>
<ReverseButton
className="absolute bottom-[20px] right-[20px]"
onClick={() => {
dispatch(logout());
}}
text="Выход"
color="error"
/>
{username == myname && (
<ReverseButton
className="absolute bottom-[20px] right-[20px]"
onClick={() => {
dispatch(logout());
}}
text="Выход"
color="error"
/>
)}
</div>
);
};

View File

@@ -3,16 +3,25 @@ import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
import { cn } from '../../../../lib/cn';
import { ChevroneDown, Edit } from '../../../../assets/icons/groups';
import { fetchMyArticles } from '../../../../redux/slices/articles';
import { useNavigate } from 'react-router-dom';
export interface ArticleItemProps {
id: number;
name: string;
tags: string[];
createdAt: string;
}
const ArticleItem: FC<ArticleItemProps> = ({ id, name, tags }) => {
export const formatDate = (isoDate?: string): string => {
if (!isoDate) return '';
const date = new Date(isoDate);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`;
};
const ArticleItem: FC<ArticleItemProps> = ({ id, name, createdAt }) => {
const navigate = useNavigate();
return (
@@ -35,18 +44,8 @@ const ArticleItem: FC<ArticleItemProps> = ({ id, name, tags }) => {
</div>
</div>
<div className="text-[14px] flex text-liquid-light gap-[10px] mt-[10px]">
{tags.map((v, i) => (
<div
key={i}
className={cn(
'rounded-full px-[16px] py-[8px] bg-liquid-lighter',
v === 'Sertificated' && 'text-liquid-green',
)}
>
{v}
</div>
))}
<div className="text-[18px] flex text-liquid-light gap-[10px] mt-[20px]">
{`Опубликована ${formatDate(createdAt)}`}
</div>
<img
@@ -72,20 +71,12 @@ const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
const dispatch = useAppDispatch();
const [active, setActive] = useState<boolean>(true);
// ✅ Берём только "мои статьи"
const articles = useAppSelector(
(state) => state.articles.fetchMyArticles.articles,
);
const status = useAppSelector(
(state) => state.articles.fetchMyArticles.status,
);
const error = useAppSelector(
(state) => state.articles.fetchMyArticles.error,
const { data: articleData } = useAppSelector(
(state) => state.profile.articles,
);
useEffect(() => {
dispatch(setMenuActiveProfilePage('articles'));
dispatch(fetchMyArticles());
}, [dispatch]);
return (
@@ -130,19 +121,21 @@ const ArticlesBlock: FC<ArticlesBlockProps> = ({ className = '' }) => {
</div>
)}
{status === 'failed' && (
<div className="text-liquid-red">
Ошибка:{' '}
{error || 'Не удалось загрузить статьи'}
</div>
<div className="text-liquid-red">Ошибка: </div>
)}
{status === 'successful' &&
articles.length === 0 && (
articleData?.articles.items.length === 0 && (
<div className="text-liquid-light">
У вас пока нет статей
</div>
)}
{articles.map((v) => (
<ArticleItem key={v.id} {...v} />
{articleData?.articles.items.map((v, i) => (
<ArticleItem
key={i}
id={v.articleId}
name={v.title}
createdAt={v.createdAt}
/>
))}
</div>
</div>

View File

@@ -1,25 +1,18 @@
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { setMenuActiveProfilePage } from '../../../../redux/slices/store';
import {
fetchMyContests,
fetchRegisteredContests,
} from '../../../../redux/slices/contests';
import ContestsBlock from './ContestsBlock';
const Contests = () => {
const dispatch = useAppDispatch();
// Redux-состояния
const myContestsState = useAppSelector(
(state) => state.contests.fetchMyContests,
const { data: constestData } = useAppSelector(
(state) => state.profile.contests,
);
// При загрузке страницы — выставляем вкладку и подгружаем контесты
useEffect(() => {
dispatch(setMenuActiveProfilePage('contests'));
dispatch(fetchMyContests());
dispatch(fetchRegisteredContests({}));
}, []);
return (
@@ -29,30 +22,38 @@ const Contests = () => {
<ContestsBlock
className="mb-[20px]"
title="Предстоящие контесты"
type="reg"
// contests={regContestsState.contests}
contests={[]}
type="upcoming"
contests={constestData?.upcoming.items
.filter((v) => v.role != 'Organizer')
.filter((v) => v.scheduleType != 'AlwaysOpen')}
/>
</div>
<div>
<ContestsBlock
className="mb-[20px]"
title="Прошедшие контесты"
type="past"
contests={[
...(constestData?.past.items.filter(
(v) => v.role != 'Organizer',
) ?? []),
...(constestData?.upcoming.items
.filter((v) => v.role != 'Organizer')
.filter((v) => v.scheduleType == 'AlwaysOpen') ??
[]),
]}
/>
</div>
{/* Контесты, которые я создал */}
<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}
/>
)}
<ContestsBlock
className="mb-[20px]"
title="Созданные контесты"
type="edit"
contests={constestData?.mine.items}
/>
</div>
</div>
);

View File

@@ -1,22 +1,23 @@
import { useState, FC } from 'react';
import { cn } from '../../../../lib/cn';
import { ChevroneDown } from '../../../../assets/icons/groups';
import MyContestItem from './MyContestItem';
import RegisterContestItem from './RegisterContestItem';
import { Contest } from '../../../../redux/slices/contests';
import { ContestItem } from '../../../../redux/slices/profile';
import PastContestItem from './PastContestItem';
import UpcoingContestItem from './UpcomingContestItem';
import EditContestItem from './EditContestItem';
interface ContestsBlockProps {
contests: Contest[];
contests?: ContestItem[];
title: string;
className?: string;
type?: 'my' | 'reg';
type?: 'edit' | 'upcoming' | 'past';
}
const ContestsBlock: FC<ContestsBlockProps> = ({
contests,
title,
className,
type = 'my',
type = 'edit',
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
@@ -36,11 +37,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',
)}
/>
@@ -53,35 +54,38 @@ const ContestsBlock: FC<ContestsBlockProps> = ({
>
<div className="overflow-hidden">
<div className="pb-[10px] pt-[20px]">
{contests.map((v, i) => {
return type == 'my' ? (
<MyContestItem
key={i}
id={v.id}
name={v.name}
startAt={v.startsAt ?? ''}
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'}
/>
);
{contests?.map((v, i) => {
if (type == 'past') {
return (
<PastContestItem
key={i}
{...v}
type={i % 2 ? 'second' : 'first'}
/>
);
}
if (type == 'upcoming') {
return (
<UpcoingContestItem
key={i}
{...v}
type={i % 2 ? 'second' : 'first'}
/>
);
}
if (type == 'edit') {
return (
<EditContestItem
key={i}
{...v}
type={i % 2 ? 'second' : 'first'}
/>
);
}
return <></>;
})}
</div>
</div>

View File

@@ -0,0 +1,146 @@
import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '../../../../redux/hooks';
import { useQuery } from '../../../../hooks/useQuery';
import { toastWarning } from '../../../../lib/toastNotification';
import { Edit } from '../../../../assets/icons/input';
export interface EditContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
role: string;
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} мин`;
}
}
const EditContestItem: React.FC<EditContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
const started = new Date(startsAt) <= new Date();
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',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
username == myname
? 'grid-cols-[1fr,150px,190px,110px,130px,24px]'
: 'grid-cols-[1fr,150px,190px,110px,130px]',
)}
onClick={() => {
if (!started) {
toastWarning('Контест еще не начался');
return;
}
const params = new URLSearchParams({
back: '/home/account/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">
{new Date() < new Date(startsAt) ? (
<>{'Не начался'}</>
) : (
<>
{scheduleType == 'AlwaysOpen'
? 'Открыт'
: new Date() < new Date(endsAt)
? 'Идет'
: 'Завершен'}
</>
)}
</div>
{username == myname && (
<img
className=" h-[24px] w-[24px] hover:bg-liquid-light rounded-[5px] transition-all duration-300"
src={Edit}
onClick={(e) => {
e.stopPropagation();
navigate(
`/contest/create?back=/home/account/contests&contestId=${contestId}`,
);
}}
/>
)}
</div>
);
};
export default EditContestItem;

View File

@@ -1,98 +0,0 @@
import { cn } from '../../../../lib/cn';
import { Account } from '../../../../assets/icons/auth';
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();
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/create?back=/home/account/contests&contestId=${id}`,
);
}}
/>
</div>
);
};
export default ContestItem;

View File

@@ -0,0 +1,112 @@
import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '../../../../redux/hooks';
import { useQuery } from '../../../../hooks/useQuery';
export interface PastContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
role: string;
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} мин`;
}
}
const PastContestItem: React.FC<PastContestItemProps> = ({
name,
contestId,
scheduleType,
startsAt,
endsAt,
attemptDurationMinutes,
type,
}) => {
const navigate = useNavigate();
const myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
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 grid-cols-[1fr,150px,190px,120px,150px] items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
const params = new URLSearchParams({
back: '/home/account/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>
</div>
);
};
export default PastContestItem;

View File

@@ -1,114 +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';
export interface ContestItemProps {
id: number;
name: string;
startAt: string;
duration: number;
members: number;
statusRegister: 'reg' | 'nonreg';
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,
statusRegister,
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',
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={() => {
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">
{statusRegister == 'reg' ? (
<>
{' '}
<PrimaryButton onClick={() => {}} text="Регистрация" />
</>
) : (
<>
{' '}
<ReverseButton onClick={() => {}} text="Вы записаны" />
</>
)}
</div>
</div>
);
};
export default ContestItem;

View File

@@ -0,0 +1,160 @@
import { cn } from '../../../../lib/cn';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '../../../../redux/hooks';
import { useQuery } from '../../../../hooks/useQuery';
import { toastWarning } from '../../../../lib/toastNotification';
export interface UpcoingContestItemProps {
name: string;
contestId: number;
scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow';
visibility: string;
startsAt: string;
endsAt: string;
attemptDurationMinutes: number;
role: string;
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 myname = useAppSelector((state) => state.auth.username);
const query = useQuery();
const username = query.get('username') ?? myname ?? '';
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();
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 grid-cols-[1fr,150px,190px,110px,110px,130px] items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (!started) {
toastWarning('Контест еще не начался');
return;
}
const params = new URLSearchParams({
back: '/home/account/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>
</div>
);
};
export default UpcoingContestItem;

View File

@@ -5,7 +5,6 @@ import { cn } from '../../../../lib/cn';
import MissionsBlock from './MissionsBlock';
import {
deleteMission,
fetchMyMissions,
setMissionsStatus,
} from '../../../../redux/slices/missions';
import ConfirmModal from '../../../../components/modal/ConfirmModal';
@@ -43,14 +42,16 @@ const Item: FC<ItemProps> = ({
const Missions = () => {
const dispatch = useAppDispatch();
const missions = useAppSelector((state) => state.missions.missions);
const status = useAppSelector((state) => state.missions.statuses.fetchMy);
const [modalDeleteTask, setModalDeleteTask] = useState<boolean>(false);
const [taskdeleteId, setTaskDeleteId] = useState<number>(0);
const { data: missionData } = useAppSelector(
(state) => state.profile.missions,
);
useEffect(() => {
dispatch(setMenuActiveProfilePage('missions'));
dispatch(fetchMyMissions());
}, []);
useEffect(() => {
@@ -66,42 +67,39 @@ const Missions = () => {
</div>
<div className="flex flex-row justify-between items-start">
<div className="flex gap-[10px]">
<Item count={14} totalCount={123} title="Задачи" />
<Item
count={missionData?.summary?.total?.solved ?? 0}
totalCount={
missionData?.summary?.total?.total ?? 0
}
title={
missionData?.summary?.total?.label ??
'Задачи'
}
/>
</div>
<div className="flex gap-[20px]">
<Item
count={14}
totalCount={123}
title="Easy"
color="green"
/>
<Item
count={14}
totalCount={123}
title="Medium"
color="orange"
/>
<Item
count={14}
totalCount={123}
title="Hard"
color="red"
/>
{missionData?.summary?.buckets?.map((bucket) => (
<Item
key={bucket.key}
count={bucket.solved}
totalCount={bucket.total}
title={bucket.label}
color={
bucket.key === 'easy'
? 'green'
: bucket.key === 'medium'
? 'orange'
: 'red'
}
/>
))}
</div>
</div>
<div className="text-[24px] font-bold text-liquid-white">
Компетенции
</div>
<div className="flex flex-wrap gap-[10px]">
<Item count={14} totalCount={123} title="Массивы" />
<Item count={14} totalCount={123} title="Списки" />
<Item count={14} totalCount={123} title="Стэк" />
</div>
</div>
<div className="p-[20px]">
<MissionsBlock
missions={missions ?? []}
missions={missionData?.authored.items ?? []}
title="Мои миссии"
setTastDeleteId={setTaskDeleteId}
setDeleteModalActive={setModalDeleteTask}

View File

@@ -2,10 +2,10 @@ import { useState, FC } from 'react';
import { cn } from '../../../../lib/cn';
import { ChevroneDown } from '../../../../assets/icons/groups';
import MyMissionItem from './MyMissionItem';
import { Mission } from '../../../../redux/slices/missions';
import { MissionItem } from '../../../../redux/slices/profile';
interface MissionsBlockProps {
missions: Mission[];
missions: MissionItem[];
title: string;
className?: string;
setTastDeleteId: (v: number) => void;
@@ -58,11 +58,11 @@ const MissionsBlock: FC<MissionsBlockProps> = ({
{missions.map((v, i) => (
<MyMissionItem
key={i}
id={v.id}
name={v.name}
timeLimit={v.timeLimit}
memoryLimit={v.memoryLimit}
difficulty={v.difficulty}
id={v.missionId}
name={v.missionName}
timeLimit={v.timeLimitMilliseconds}
memoryLimit={v.memoryLimitBytes}
difficulty={v.difficultyValue}
type={i % 2 ? 'second' : 'first'}
setTastDeleteId={setTastDeleteId}
setDeleteModalActive={setDeleteModalActive}

View File

@@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { useNavigate } from 'react-router-dom';
import { arrowLeft } from '../../../assets/icons/header';
import { useQuery } from '../../../hooks/useQuery';
export interface Article {
id: number;
@@ -25,6 +26,10 @@ interface ContestMissionsProps {
const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const query = useQuery();
const url = query.get('back') ?? '/home/contests';
const { status } = useAppSelector(
(state) => state.contests.fetchMySubmissions,
);
@@ -113,7 +118,7 @@ const ContestMissions: FC<ContestMissionsProps> = ({ contest }) => {
src={arrowLeft}
className="cursor-pointer"
onClick={() => {
navigate(`/home/contests`);
navigate(url);
}}
/>
<span className="text-liquid-light font-bold text-[18px]">

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;

View File

@@ -36,7 +36,10 @@ const Group: FC<GroupsBlockProps> = () => {
<Routes>
<Route path="home" element={<Posts groupId={groupId} />} />
<Route path="chat" element={<Chat groupId={groupId} />} />
<Route path="contests" element={<Contests />} />
<Route
path="contests"
element={<Contests groupId={groupId} />}
/>
<Route
path="*"
element={<Navigate to={`/group/${groupId}/home`} />}

View File

@@ -6,7 +6,6 @@ import {
sendGroupMessage,
setGroupChatStatus,
} from '../../../../redux/slices/groupChat';
import { SearchInput } from '../../../../components/input/SearchInput';
import { MessageItem } from './MessageItem';
import { Send } from '../../../../assets/icons/input';
@@ -169,15 +168,7 @@ export const Chat: FC<GroupChatProps> = ({ groupId }) => {
return (
<div className="h-full relative">
<div className="grid grid-rows-[40px,1fr,40px] h-full relative min-h-0 gap-[20px]">
<div className="relative">
<SearchInput
className="w-[216px]"
onChange={() => {}}
placeholder="Поиск сообщений"
/>
</div>
<div className="grid grid-rows-[1fr,40px] h-full relative min-h-0 gap-[20px]">
<div
className="min-h-0 overflow-y-scroll thin-dark-scrollbar"
ref={scrollRef}

View File

@@ -1,17 +1,75 @@
import { useEffect } from 'react';
import { useAppDispatch } from '../../../../redux/hooks';
import { FC, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import ContestsBlock from './ContestsBlock';
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
import {
fetchContests,
fetchMyContests,
fetchParticipatingContests,
} from '../../../../redux/slices/contests';
export const Contests = () => {
interface ContestsProps {
groupId: number;
}
export const Contests: FC<ContestsProps> = ({ groupId }) => {
const dispatch = useAppDispatch();
const { contests, status } = useAppSelector(
(state) => state.contests.fetchContests,
);
useEffect(() => {
dispatch(setMenuActiveGroupPage('contests'));
dispatch(fetchContests({ groupId }));
dispatch(fetchParticipatingContests({ pageSize: 100 }));
dispatch(fetchMyContests());
}, []);
return (
<div className="h-full overflow-y-scroll thin-dark-scrollbar flex items-center justify-center font-bold text-liquid-white text-[50px]">
{' '}
Пока пусто :(
<div className="h-full relative">
<div className="grid grid-rows-[1fr] h-full relative min-h-0 gap-[20px]">
<div className="min-h-0 overflow-y-scroll thin-dark-scrollbar pr-[20px]">
{status == 'loading' && (
<div className="text-liquid-white p-4">
Загрузка контестов...
</div>
)}
{status == 'successful' && (
<div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
<ContestsBlock
groupId={groupId}
className="mb-[20px]"
title="Текущие"
contests={contests
.filter(
(c) => c.scheduleType != 'AlwaysOpen',
)
.filter((c) =>
c.endsAt
? new Date() < new Date(c.endsAt)
: false,
)}
type="upcoming"
/>
<ContestsBlock
groupId={groupId}
className="mb-[20px]"
title="Прошедшие"
contests={contests.filter(
(c) =>
c.scheduleType == 'AlwaysOpen' ||
!(c.endsAt
? new Date() < new Date(c.endsAt)
: false),
)}
type="past"
/>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { useState, FC } from 'react';
import { cn } from '../../../../lib/cn';
import { ChevroneDown } from '../../../../assets/icons/groups';
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';
groupId: number;
}
const ContestsBlock: FC<ContestsBlockProps> = ({
contests,
title,
className,
type,
groupId,
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
return (
<div
className={cn(
' border-b-[1px] border-b-liquid-lighter rounded-[10px]',
className,
)}
>
<div
className={cn(
' h-[40px] text-[24px] font-bold flex gap-[10px] items-center cursor-pointer border-b-[1px] border-b-transparent transition-all duration-300',
active && 'border-b-liquid-lighter',
)}
onClick={() => {
setActive(!active);
}}
>
<span className=" select-none">{title}</span>
<img
src={ChevroneDown}
className={cn(
'transition-all duration-300 select-none',
active && 'rotate-180',
)}
/>
</div>
<div
className={cn(
' grid grid-flow-row grid-rows-[0fr] opacity-0 transition-all duration-300',
active && 'grid-rows-[1fr] opacity-100',
)}
>
<div className="overflow-hidden">
<div className="pb-[10px] pt-[20px]">
{contests.map((v, i) => {
if (type == 'past') {
return (
<PastContestItem
groupId={groupId}
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
groupId={groupId}
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>
</div>
);
};
export default ContestsBlock;

View File

@@ -0,0 +1,222 @@
import { FC, useEffect, useState } from 'react';
import { Modal } from '../../../../components/modal/Modal';
import { PrimaryButton } from '../../../../components/button/PrimaryButton';
import { SecondaryButton } from '../../../../components/button/SecondaryButton';
import { Input } from '../../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import {
createContest,
setContestStatus,
} from '../../../../redux/slices/contests';
import { CreateContestBody } from '../../../../redux/slices/contests';
import DateRangeInput from '../../../../components/input/DateRangeInput';
import { useNavigate } from 'react-router-dom';
function toUtc(localDateTime?: string): string {
if (!localDateTime) return '';
// Создаём дату (она автоматически считается как локальная)
const date = new Date(localDateTime);
// Возвращаем ISO-строку с 'Z' (всегда в UTC)
return date.toISOString();
}
interface ModalCreateContestProps {
active: boolean;
setActive: (value: boolean) => void;
}
const ModalCreateContest: FC<ModalCreateContestProps> = ({
active,
setActive,
}) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const status = useAppSelector(
(state) => state.contests.createContest.status,
);
const [form, setForm] = useState<CreateContestBody>({
name: '',
description: '',
scheduleType: 'AlwaysOpen',
visibility: 'Public',
startsAt: '',
endsAt: '',
attemptDurationMinutes: 0,
maxAttempts: 0,
allowEarlyFinish: false,
missionIds: [],
articleIds: [],
});
const contest = useAppSelector(
(state) => state.contests.createContest.contest,
);
useEffect(() => {
if (status === 'successful') {
dispatch(
setContestStatus({ key: 'createContest', status: 'idle' }),
);
navigate(
`/contest/create?back=/home/account/contests&contestId=${contest.id}`,
);
}
}, [status]);
const handleChange = (key: keyof CreateContestBody, value: any) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = () => {
dispatch(
createContest({
...form,
endsAt: toUtc(form.endsAt),
startsAt: toUtc(form.startsAt),
}),
);
};
return (
<Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[550px]">
<div className="font-bold text-[30px] mb-[10px]">
Создать контест
</div>
<Input
name="name"
type="text"
label="Название"
className="mt-[10px]"
placeholder="Введите название"
onChange={(v) => handleChange('name', v)}
/>
<Input
name="description"
type="text"
label="Описание"
className="mt-[10px]"
placeholder="Введите описание"
onChange={(v) => handleChange('description', v)}
/>
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<div>
<label className="block text-sm mb-1">
Тип расписания
</label>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={form.scheduleType}
onChange={(e) =>
handleChange(
'scheduleType',
e.target
.value as CreateContestBody['scheduleType'],
)
}
>
<option value="AlwaysOpen">Всегда открыт</option>
<option value="FixedWindow">
Фиксированные даты
</option>
<option value="RollingWindow">
Скользящее окно
</option>
</select>
</div>
<div>
<label className="block text-sm mb-1">Видимость</label>
<select
className="w-full p-2 rounded-md bg-liquid-darker border border-liquid-lighter"
value={form.visibility}
onChange={(e) =>
handleChange(
'visibility',
e.target
.value as CreateContestBody['visibility'],
)
}
>
<option value="Public">Публичный</option>
<option value="GroupPrivate">Групповой</option>
</select>
</div>
</div>
{/* Даты начала и конца */}
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<DateRangeInput
startValue={form.startsAt || ''}
endValue={form.endsAt || ''}
onChange={handleChange}
className="mt-[10px]"
/>
</div>
{/* Продолжительность и лимиты */}
<div className="grid grid-cols-2 gap-[10px] mt-[10px]">
<Input
name="attemptDurationMinutes"
type="number"
label="Длительность попытки (мин)"
placeholder="Например: 60"
onChange={(v) =>
handleChange('attemptDurationMinutes', Number(v))
}
/>
<Input
name="maxAttempts"
type="number"
label="Макс. попыток"
placeholder="Например: 3"
onChange={(v) => handleChange('maxAttempts', Number(v))}
/>
</div>
{/* Разрешить раннее завершение */}
<div className="flex items-center gap-[10px] mt-[15px]">
<input
id="allowEarlyFinish"
type="checkbox"
checked={!!form.allowEarlyFinish}
onChange={(e) =>
handleChange('allowEarlyFinish', e.target.checked)
}
/>
<label htmlFor="allowEarlyFinish">
Разрешить раннее завершение
</label>
</div>
{/* Кнопки */}
<div className="flex flex-row w-full items-center justify-end mt-[20px] gap-[20px]">
<PrimaryButton
onClick={() => {
handleSubmit();
}}
text="Создать"
disabled={status === 'loading'}
/>
<SecondaryButton
onClick={() => setActive(false)}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalCreateContest;

View File

@@ -0,0 +1,191 @@
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';
groupId: number;
}
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,
groupId,
}) => {
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: `/group/${groupId}/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: `/group/${groupId}/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,228 @@
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';
groupId: number;
}
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,
groupId,
}) => {
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 items-center font-bold border-transparent hover:border-liquid-darkmain border-solid border-[1px] transition-all duration-300 grid grid-flow-col',
userId
? 'grid-cols-[1fr,1fr,190px,130px,130px,150px]'
: 'grid-cols-[1fr,1fr,190px,130px,130px]',
type == 'first'
? ' bg-liquid-lighter'
: ' bg-liquid-background',
)}
onClick={() => {
if (!started) {
toastWarning('Контест еще не начался');
return;
}
const params = new URLSearchParams({
back: `/group/${groupId}/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>
)
)}
{userId && (
<div className="flex items-center justify-center">
{role == 'Organizer' || role == 'Participant' ? (
<ReverseButton
onClick={() => {
const params = new URLSearchParams({
back: `/group/${groupId}/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;

View File

@@ -54,47 +54,55 @@ export const Posts: FC<PostsProps> = ({ groupId }) => {
const page0 = pages[0];
return (
<div className="h-full overflow-y-scroll thin-dark-scrollbar">
<div className="h-[40px] mb-[20px] relative">
<SearchInput
className="w-[216px]"
onChange={(v) => {
v;
}}
placeholder="Поиск сообщений"
/>
{isAdmin && (
<div className=" h-[40px] w-[180px] absolute top-0 right-0 flex items-center">
<SecondaryButton
onClick={() => {
setModalCreateActive(true);
}}
text="Создать пост"
/>
</div>
)}
</div>
{status === 'loading' && <div>Загрузка...</div>}
{status === 'failed' && <div>Ошибка загрузки постов</div>}
{status == 'successful' &&
page0?.items &&
page0.items.length > 0 ? (
<div className="flex flex-col gap-[20px]">
{page0.items.map((post, i) => (
<PostItem
{...post}
key={i}
isAdmin={isAdmin}
setModalUpdateActive={setModalUpdateActive}
setUpdatePostId={setUpdatePostId}
/>
))}
<div className="h-full relative">
<div className="grid grid-rows-[40px,1fr,40px] h-full relative min-h-0 gap-[20px]">
<div className="h-[40px] mb-[20px] relative">
<SearchInput
className="w-[216px]"
onChange={(v) => {
v;
}}
placeholder="Поиск сообщений"
/>
{isAdmin && (
<div className=" h-[40px] w-[180px] absolute top-0 right-0 flex items-center">
<SecondaryButton
onClick={() => {
setModalCreateActive(true);
}}
text="Создать пост"
/>
</div>
)}
</div>
) : status === 'successful' ? (
<div>Постов пока нет</div>
) : null}
<>
{status === 'loading' && <div>Загрузка...</div>}
{status === 'failed' && <div>Ошибка загрузки постов</div>}
{status == 'successful' &&
page0?.items &&
page0.items.length > 0 ? (
<div className="min-h-0 overflow-y-scroll thin-dark-scrollbar">
<div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
{page0.items.map((post, i) => (
<PostItem
{...post}
key={i}
isAdmin={isAdmin}
setModalUpdateActive={
setModalUpdateActive
}
setUpdatePostId={setUpdatePostId}
/>
))}
</div>
</div>
) : status === 'successful' ? (
<div>Постов пока нет</div>
) : null}
</>
</div>
<ModalCreate
active={modalCreateActive}

View File

@@ -1,9 +1,17 @@
import { SearchInput } from '../../../components/input/SearchInput';
import { useAppDispatch } from '../../../redux/hooks';
import { setGroupFilter } from '../../../redux/slices/store';
const Filters = () => {
const dispatch = useAppDispatch();
return (
<div className=" h-[50px] mb-[20px] flex gap-[20px] items-center">
<SearchInput onChange={() => {}} placeholder="Поиск группы" />
<SearchInput
onChange={(v: string) => {
dispatch(setGroupFilter(v));
}}
placeholder="Поиск группы"
/>
</div>
);
};

View File

@@ -1,18 +1,12 @@
import { cn } from '../../../lib/cn';
import {
Book,
UserAdd,
Edit,
EyeClosed,
EyeOpen,
} from '../../../assets/icons/groups';
import { Book, UserAdd, Edit } from '../../../assets/icons/groups';
import { useNavigate } from 'react-router-dom';
import { GroupInvite, GroupUpdate } from './Groups';
import { useAppSelector } from '../../../redux/hooks';
export interface GroupItemProps {
id: number;
role: 'menager' | 'member' | 'owner' | 'viewer';
visible: boolean;
name: string;
description: string;
setUpdateActive: (value: any) => void;
@@ -43,7 +37,6 @@ const IconComponent: React.FC<IconComponentProps> = ({ src, onClick }) => {
const GroupItem: React.FC<GroupItemProps> = ({
id,
name,
visible,
description,
setUpdateGroup,
setUpdateActive,
@@ -53,6 +46,61 @@ const GroupItem: React.FC<GroupItemProps> = ({
}) => {
const navigate = useNavigate();
const filter = useAppSelector(
(state) => state.store.group.groupFilter,
).toLowerCase();
const highlightZ = (name: string, filter: string) => {
if (!filter) return name;
const s = filter.toLowerCase();
const t = name.toLowerCase();
const n = t.length;
const m = s.length;
const mark = Array(n).fill(false);
// Проходимся с конца и ставим отметки
for (let i = n - 1; i >= 0; i--) {
if (i + m <= n && t.slice(i, i + m) === s) {
for (let j = i; j < i + m; j++) {
if (mark[j]) break;
mark[j] = true;
}
}
}
// === Формируем единые жёлтые блоки ===
const result: any[] = [];
let i = 0;
while (i < n) {
if (!mark[i]) {
// обычный символ
result.push(name[i]);
i++;
} else {
// начинаем жёлтый блок
let j = i;
while (j < n && mark[j]) j++;
const chunk = name.slice(i, j);
result.push(
<span
key={i}
className="bg-yellow-400 text-black rounded px-1"
>
{chunk}
</span>,
);
i = j;
}
}
return result;
};
return (
<div
className={cn(
@@ -66,7 +114,10 @@ const GroupItem: React.FC<GroupItemProps> = ({
className="bg-liquid-brightmain rounded-[10px]"
/>
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
<div className="text-[18px] font-bold">{name}</div>
<div className="text-[18px] font-bold">
{highlightZ(name, filter)}
</div>
<div className=" flex gap-[10px]">
{type == 'manage' && (
<IconComponent
@@ -86,8 +137,6 @@ const GroupItem: React.FC<GroupItemProps> = ({
}}
/>
)}
{visible == false && <IconComponent src={EyeOpen} />}
{visible == true && <IconComponent src={EyeClosed} />}
</div>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import { cn } from '../../../lib/cn';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks';
import GroupsBlock from './GroupsBlock';
import { setMenuActivePage } from '../../../redux/slices/store';
import { fetchMyGroups } from '../../../redux/slices/groups';
import { fetchMyGroups, Group } from '../../../redux/slices/groups';
import ModalCreate from './ModalCreate';
import ModalUpdate from './ModalUpdate';
import Filters from './Filter';
@@ -45,6 +45,7 @@ const Groups = () => {
const groupsError = useAppSelector(
(store) => store.groups.fetchMyGroups.error,
);
const filter = useAppSelector((state) => state.store.group.groupFilter);
// Берём текущего пользователя
const currentUserName = useAppSelector((store) => store.auth.username);
@@ -54,17 +55,21 @@ const Groups = () => {
dispatch(fetchMyGroups());
}, [dispatch]);
// Разделяем группы
const { managedGroups, currentGroups, hiddenGroups } = useMemo(() => {
const applyFilter = (groups: Group[], filter: string) => {
if (!filter || filter.trim() === '') return groups;
const normalized = filter.toLowerCase();
return groups.filter((g) => g.name.toLowerCase().includes(normalized));
};
const { managedGroups, currentGroups } = useMemo(() => {
if (!groups || !currentUserName) {
return { managedGroups: [], currentGroups: [], hiddenGroups: [] };
return { managedGroups: [], currentGroups: [] };
}
const managed: typeof groups = [];
const current: typeof groups = [];
const hidden: typeof groups = []; // пока пустые, без логики
groups.forEach((group) => {
applyFilter(groups, filter).forEach((group) => {
const me = group.members.find(
(m) => m.username === currentUserName,
);
@@ -80,9 +85,8 @@ const Groups = () => {
return {
managedGroups: managed,
currentGroups: current,
hiddenGroups: hidden,
};
}, [groups, currentUserName]);
}, [groups, currentUserName, filter]);
return (
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20px]">
@@ -137,16 +141,6 @@ const Groups = () => {
setInviteGroup={setInviteGroup}
type="member"
/>
<GroupsBlock
className="mb-[20px]"
title="Скрытые"
groups={hiddenGroups} // пока пусто
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
setInviteActive={setModalInviteActive}
setInviteGroup={setInviteGroup}
type="member"
/>
</>
)}
</div>

View File

@@ -65,7 +65,6 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
<GroupItem
key={i}
id={v.id}
visible={true}
description={v.description}
setUpdateActive={setUpdateActive}
setUpdateGroup={setUpdateGroup}

View File

@@ -1,24 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "src/views/home/account/contests/.tsx"]
}